CRADL Enemy System
Companion to COMBAT_SYSTEM.md (the combat contract) and ARCHITECTURE.md (the foundation). This document is the contract the enemy subsystem must satisfy — its pawn shape, brain/body split, data model, hostility rules, drop pipeline, and spawn lifecycle. Implementation patterns, per-enemy authoring numbers, and per-encounter design briefs live elsewhere; what's here does not change without a deliberate edit to this file.
The combat contract is forward-compatible with non-player participants by design (see COMBAT_SYSTEM.md "NPC / AI Compatibility"). This doc does not redesign combat. It defines the surfaces an enemy participant binds to and the data shape that drives enemy-specific behavior.
North Star
Enemies are OSRS-derived: stationary or simple-patrol denizens of the world that decide whether to engage and then execute that engagement through the same combat verbs the player uses. The decision-making (perception, target selection, threat, patrol routing) lives in the AI controller's brain — a Behavior Tree, not GAS. The execution (swing, cast, hit, die) lives in the pawn's body — UCradlGameplayAbilitys, indistinguishable in shape from the player's. Enemy data lives in UEnemyDefinition and applies at spawn time against an empty AEnemyCharacter CDO.
Three rules carry the weight:
1. One combat contract. Enemies fire Action.Trigger.Combat.Engage through ASC->HandleGameplayEvent exactly like the player click path; they go through IPawnCombatant::BeginChase to close distance; they receive damage through the same UCradlAttributeSet::Damage meta-attribute. There is no enemy-specific damage path, no Server_EnemyAttack RPC, no parallel state machine.
2. Brain in BT, body in GAS. Decision-making is a predicate problem (when, who, where). Behavior Trees are good at predicates. Combat verbs are commitment problems (this swing, this cast). GAS is good at commitment. Don't force one shape into the other.
3. Data drives variation. A single enemy class (AEnemyCharacter) hosts every monster in the game. UEnemyDefinition injects the per-type values at BeginPlay; FEnemyVariant injects per-instance permutation (spear vs sword Goblin) at spawn-roll time. No subclasses per monster type.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Pawn class | AEnemyCharacter : ACharacter, owns own ASC (no PlayerState) |
Pawn Shape |
| Reused contracts | IAbilitySystemInterface, IPawnCombatant, IInteractable, ICueAnchorProvider |
Pawn Shape |
| Attribute set | UCradlAttributeSet (verbatim — same Health/MaxHealth/Damage meta/AttackSpeed) |
Pawn Shape |
| Brain | AEnemyAIController + Behavior Tree; implements IMoveIssuer |
AI Brain |
| Body | Same GAS abilities the player uses (UAutoAttackAbility, UCastSpellAbility) |
Combat Reuse |
| Engage dispatch | BT task fires Action.Trigger.Combat.Engage on the enemy's ASC |
AI Brain |
| Definition asset | UEnemyDefinition : UPrimaryDataAsset — CDO + pump-at-spawn |
Enemy Definition |
| Permutations | Per-definition TArray<FWeightedEnemyVariant> rolled at spawn (NOT player Loadouts) |
Variants |
| Stat tuning | TArray<FEnemyStatTuning> (Min..Max ranges) rolled once at spawn into a transient innate-stats GE |
Stat Tuning |
| Hostility | Per-definition Aggressive / Passive; OSRS level-gate on Aggressive; all enemies retaliate when struck | Hostility |
| Combat level | CradlCombat::ComputeCombatLevel(PS) = max(1, (Def+HP)/4 + best(Melee,Ranged,Magic)*13/40 + 3) |
Combat Level Scalar |
| Group aggro | Opt-in per definition (GroupAggroRadiusCm, default 0 = off); one-hop broadcast to same-FamilyTag neighbors |
Group Aggro |
| Patrol | Stationary / RandomRadius / Spline — routes through IMoveIssuer::IssueMoveTo, NOT chase |
Patrol |
| Leashing | Per-definition LeashRadiusCm (default 1500, 0 = none) with optional spawner override; abort + heal-to-full on return to anchor |
Leashing |
| Difficulty scaling | None. A level-10 Goblin is always a level-10 Goblin — OSRS-faithful by design | Stat Tuning |
| Drop tables | UDropTableDefinition : UPrimaryDataAsset; composable; entries resolve to FName ItemId against the items DataTable |
Drop Tables |
| Drop attribution | Most-damage-dealt wins; ownership window on ground items | Loot Attribution |
| Spawners | AEnemySpawner placeable actor, server-only; respawn cadence + max-alive |
Spawners |
| Death | Combat.Event.Death → UEnemyDeathAbility (separate from player) → drop roll → ground items → notify spawner |
Death |
| Multi-attacker cap | Already in combat contract — enemies use the receiving side verbatim | COMBAT_SYSTEM.md Multi-Attacker Ceiling |
| Net policy | Enemies are server-spawned, server-owned; enemy abilities NetExecutionPolicy = ServerOnly |
Authority & Replication |
| Tag namespaces | Enemy.*, plus reuse of Combat.* / Action.* from the combat contract |
Tag Taxonomy |
| Open questions | Combat level scalar, leashing, group aggro, elite/boss variants, scaling | Open Questions |
Pawn Shape
Rule: A single pawn class AEnemyCharacter : ACharacter hosts every enemy in the game. It owns its own UAbilitySystemComponent (not on a sibling PlayerState — enemies have no PlayerState). It implements IAbilitySystemInterface, IPawnCombatant, IInteractable, and ICueAnchorProvider. It uses UCradlAttributeSet verbatim — same Health, MaxHealth, Damage meta, AttackSpeed. No subclasses per monster type.
Why: One pawn class with data-driven variation is how AGatheringNode and AGroundItem already model heterogeneity in the project — author the variation in the definition asset, not the class hierarchy. Owning the ASC on the pawn (instead of a PlayerState equivalent) is the standard mob convention: enemies have no persistent profile, no controller-handover lifecycle, and no per-player skill state to host on a separate replicated object. Implementing IInteractable is non-negotiable per the combat contract — click-to-engage routes through IInteractable::DispatchContextAction and would have no entry point otherwise.
Implementation surface:
- File: Source/CRADL/Enemy/EnemyCharacter.{h,cpp} (new).
- ASC: TObjectPtr<UCradlAbilitySystemComponent> AbilitySystem — created in the constructor, replicated, initialized for self (InitAbilityActorInfo(this, this)). GetAbilitySystemComponent() returns it directly.
- Attribute set: instantiated alongside the ASC; same UCradlAttributeSet the player uses.
- Components: UChaseComponent (verbatim reuse — chase tunables are per-pawn-class so a heavy enemy chases differently from a nimble one, exactly the surface the combat contract anticipates); server-only UEnemyDropComponent (tracks per-attacker damage totals for loot attribution).
- IInteractable impl: GetPrimaryActionTag() returns Action.Trigger.Combat.Engage; CanInteract() gates on the same engagement-range resolver + LOS the rest of combat uses; GetSourceLabel() returns the active variant's display name.
- IPawnCombatant impl: delegates to UChaseComponent, exactly as ACradlCharacter does (see IPawnCombatant impl in Source/CRADL/Player/CradlCharacter.h).
- Spawn-time entry point: ApplyDefinition(const UEnemyDefinition*, int32 VariantSeed) — called server-side from BeginPlay (or the spawner's deferred-spawn finalizer). This is the "pump."
- Default controller class: AEnemyAIController (set in constructor).
Footguns:
- The CDO is empty. Do not push UEnemyDefinition values into the pawn's UPROPERTY defaults via PostInitProperties or constructor-time hacks. The CDO is shared across every spawn; mutating it bleeds across instances. ApplyDefinition writes to this instance's component fields and applies a per-instance stats GE.
- Don't reach for APlayerState. Combat code that runs on ACradlCharacter resolves attacker XP via ACradlPlayerState; on AEnemyCharacter that returns null. The HP-XP grant path in UCradlAttributeSet::PostGameplayEffectExecute already short-circuits when attacker == victim; the per-attacker damage tracking for loot attribution lives on the victim's UEnemyDropComponent, not on a PlayerState.
- ASC on the pawn changes the GAS rules slightly. Player abilities resolve GetOwningActor() to the APlayerState and the avatar to the pawn (per ARCH #15); enemy abilities resolve both to the pawn. Code that branches on OwnerActor != AvatarActor must handle the enemy case (typically by reading the avatar, which works for both).
Related: COMBAT_SYSTEM.md "Chase & Movement", Source/CRADL/Combat/PawnCombatantInterface.h, Source/CRADL/Interaction/InteractableInterface.h.
AI Brain
Rule: Decision-making for an enemy lives on its AEnemyAIController and a per-archetype Behavior Tree. The controller implements IMoveIssuer exactly as the combat contract specifies (SimpleMoveToActor via UAIBlueprintHelperLibrary for actor targets; SimpleMoveToLocation for waypoints). The Behavior Tree drives perception, target selection, threat, hostility evaluation, and patrol routing. Combat verbs are not BT services or tasks — they are Action.Trigger.Combat.Engage events dispatched against the same UAutoAttackAbility the player uses.
Why: Behavior Trees are evaluated as predicates ("is a target in range?", "should I patrol?", "have I been struck?") — that's exactly the decision shape an enemy needs. GAS abilities are commitment ("I am swinging this weapon for the next 2.4s") — that's the verb shape combat needs. Routing decisions through GAS produces either a single mega-ability whose "task graph" is a state machine in disguise, or a tag-and-event soup that reimplements BTs without the editor tooling. The clean cut also matches what the combat contract already implies: IPawnCombatant / IMoveIssuer were split specifically so abilities address the body and the body forwards through whatever controller is driving (player or AI). The brain plugs into that split as the AI side of the IMoveIssuer impl.
Implementation surface:
- File: Source/CRADL/Enemy/EnemyAIController.{h,cpp} (new).
- Class: AEnemyAIController : AAIController, public IMoveIssuer. Overrides OnPossess(APawn*) to seed the blackboard from the possessed pawn's UEnemyDefinition (definition pointer, hostility, aggro radius, leash anchor, patrol mode).
- IMoveIssuer::IssueMoveTo(AActor*) → UAIBlueprintHelperLibrary::SimpleMoveToActor(this, Target).
- IMoveIssuer::IssueMoveTo(FVector) → UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, Loc).
- IMoveIssuer::StopMove() → AAIController::StopMovement().
- Behavior Tree: lives under Content/AI/BT_Enemy_* per archetype; a single shared base BT_Enemy_Default covers the common "patrol → detect → approach → engage → disengage" loop, with archetype-specific BTs inheriting and overriding leaf decisions.
- Engage dispatch task: BTTask_FireCombatEngage builds an FGameplayEventData with EventTag = Action.Trigger.Combat.Engage, Target = victim, Instigator = controlled pawn, and calls ASC->HandleGameplayEvent on the pawn's ASC. No bespoke "enemy attack" task; this is the same channel the player click path uses.
- Disengage: BT runs ASC->CancelAbilities({Action.Combat.AutoAttack}) then IPawnCombatant::EndChase + IPawnCombatant::StopMovement. Same teardown shape the controller's fresh-nav-cancel uses for the player.
- Perception: v1 is a hand-rolled "every N seconds, sphere-overlap + LOS trace" BT service. UAIPerceptionComponent is left out of v1 — it's heavier than needed for the OSRS-style "see things in a radius" model, and adding it later is additive.
Footguns:
- The BT does not roll damage. Damage rolls live in UCradlCombatMath and are reached through UAutoAttackAbility::HandleSwingFinished. If you find a BT task computing accuracy / damage, that's a parallel channel and the test is failing.
- The BT does not write to the ASC's combat tags. Status.InCombat, Action.Combat.AutoAttack, and friends are owned by the combat abilities and GE channel. The BT reads tags (via BTDecorator_GameplayTagQuery or a custom decorator) and fires triggers; it never writes the leaf state.
- Don't bypass IMoveIssuer. The pawn's UChaseComponent looks the controller up through IMoveIssuer for chase moves — combat code does not know whether a player or AI is driving. A BT task that calls MoveToActor directly without going through the interface bypasses the chase primitive's re-issue rate-limiting; mid-engagement chase silently regresses to "issue once and hope."
- Don't subscribe BT decorators to State.Moving. Same self-cancellation footgun the combat abilities document — the AI causes its own movement and would self-terminate.
Related: COMBAT_SYSTEM.md "Chase & Movement", COMBAT_SYSTEM.md "Targeting & Engagement Entry".
Combat Reuse
Rule: Every combat verb on an enemy runs the same engagement-loop core the player uses. The auto-attack ability is split as UCradlAutoAttackAbilityBase (shared cadence / damage / cap / range / cue / hit-event publication) + UAutoAttackAbility (player driver: spellbook autocast, style XP, "Equip a weapon" UX, PlayerState-side targeting / activity descriptor) + UEnemyAutoAttackAbility (enemy driver: pawn-side Equipment / targeting, placeholder-then-StatTuning attacker level, no autocast / no XP / no UI). UCastSpellAbility handles enemy casts (if/when an enemy authors a USpellDefinition); the same shared-base / per-actor-driver shape applies if it splits later. The Combat.Event.Death event drives an enemy-side death ability (separate from the player's — see Death). Damage flows through UCradlAttributeSet::PostGameplayEffectExecute against the same Damage meta-attribute. Hit cues fire the same GameplayCue.Combat.Hit.{Type} tags.
Why: Splitting "enemy damage" and "player damage" into parallel code is the single biggest failure mode this contract is designed to prevent — it produces "the goblin hits you for 0 in a state nobody tested" bugs. The anti-fork rule pins down the math and the verb, not the per-actor driver shell: as long as the swing cadence, damage roll, accuracy roll, cap registration, and cue/event publication all live on a single shared base class with no parallel implementations, the contract is honored. The split lets each driver own its persona (player UI / spellbook / XP routing vs. enemy targeting host / placeholder levels) without polluting the other's code path. Lyra's "AI = player ability" pattern works for short fire-and-forget abilities; CRADL's auto-attack is a long-running engagement state machine where the per-actor surface is too large to share without if (PS) / if (Skills) guards everywhere — see ENEMY_IMPLEMENTATION.md Phase 1 Pre-work for the recorded judgment. The combat contract was written with this rule already in mind (COMBAT_SYSTEM.md "NPC / AI Compatibility").
Interfaces that decouple the cast sites:
- IAttackerCappedTarget — TryRegisterAttacker / UnregisterAttacker / WouldAcceptAttacker. Implemented by ATargetDummy and AEnemyCharacter. The base auto-attack ability's cap registration goes through this interface, not a Cast<ATargetDummy>.
- ICombatStatsProvider — GetCombatSkillLevel(SkillTag) / GetDefenseBonusForType(DamageType). Implemented by ATargetDummy, ACradlPlayerState, AEnemyCharacter. The base ability's ResolveTargetDefense and UCombatStatsDebugComponent's mirror copy both go through this interface; UEnemyAutoAttackAbility::ResolveAttackerLevel reads the enemy's own stats through the same surface.
- CradlCombat::ResolveCombatContext(Info) — PS-first / avatar-fallback resolution of {Equipment, Skills, AttributeSet, Spellbook} from an ability's actor info. The single site that disambiguates "owner is PS" (player) vs. "owner == avatar == pawn" (enemy). Skills/Spellbook stay null on the enemy fallback path; subclassed abilities tolerate the null.
Implementation surface:
- Ability grant: AEnemyCharacter::ApplyDefinition grants UEnemyAutoAttackAbility (and optionally UCastSpellAbility) to the pawn's ASC on spawn. The list is read from UEnemyDefinition::GrantedAbilities so a designer can extend a new enemy with a custom ability class without touching the pawn class. Don't grant UAutoAttackAbility to an enemy — that's the player driver and reads PlayerState components.
- Equipment-derived stats: an enemy's "weapon" is an FItemRow referenced by FName ItemId from the active FEnemyVariant (see Variants). UEnemyAutoAttackAbility::SnapshotSwingStats reads weapon data through the pawn's UEquipmentComponent (resolved via CradlCombat::ResolveCombatContext's avatar-fallback path) so WeaponDamageType, SwingInterval, AttackRange, StrengthBonus, and the AttackBonus[type] / DefenseBonus[type] maps all resolve identically to the player path. Enemies host a minimal UEquipmentComponent whose only populated slot is Item.Slot.MainHand (and Item.Slot.Ammo when the variant is ranged).
- Targeting: enemies host the same UCombatTargetingComponent the player does — but on the pawn, not a PlayerState. The brain writes ActiveTarget; the auto-attack ability and AnimBP stance map read it. UEnemyAutoAttackAbility::OnEngagementBegan/Ended updates the pawn-side component; UAutoAttackAbility::OnEngagementBegan/Ended updates the PS-side one.
- Cancellation: UCradlAutoAttackAbilityBase::CancelOnTagsAdded names the universal set — Status.Stunned, Status.Dead, Action.Modal (per COMBAT_SYSTEM.md Cancellation Channels). Driver-specific cancel additions (player Action.QueuedInteract interaction, future enemy leash-break) live on the subclass. Enemy-cancel-on-fresh-input does not apply (no input); the brain cancels by calling ASC->CancelAbilities directly.
Footguns:
- UCradlAutoAttackAbilityBase is abstract by convention, not enforced. UCLASS(Abstract) blocks editor placement but doesn't block runtime grants. The base's default ResolveAttackerLevel fires an ensureMsgf so a stray grant is caught loudly in PIE. Always grant a concrete subclass (UAutoAttackAbility for player, UEnemyAutoAttackAbility for enemy).
- Enemy UEquipmentComponent is a degenerate equipment component, not a new class. Slot list is narrowed (MainHand, optional Ammo); replication is identical; the existing aggregator methods work verbatim. Don't subclass for "enemies don't have a Bag" — the slot definition list already covers that.
- UCombatTargetingComponent on the enemy pawn replicates differently than on the player's PlayerState. Player version replicates COND_OwnerOnly for cheapness; enemy version should replicate to all peers because spectator visibility (who is the goblin attacking?) is meaningful. Document the cond difference in the component if a single class wears both hats; alternatively, accept the slightly-fatter replication and use the same cond on both — the data is small.
Related: COMBAT_SYSTEM.md "GAS-Only Combat", COMBAT_SYSTEM.md "Engagement Loop", COMBAT_SYSTEM.md "Equipment Combat Data".
Enemy Definition
Rule: UEnemyDefinition : UPrimaryDataAsset is the per-type authoring surface. The AEnemyCharacter CDO is empty; ApplyDefinition pumps the definition's values into the spawned instance at BeginPlay (server). Definitions are enumerated by a lazy UEnemyRegistry : UGameInstanceSubsystem per ARCH #14, mirroring USkillRegistry / USpellRegistry / UCradlRecipeRegistry.
Why: Data-driven enemy variation is how every other "many similar things" surface in this project already works (skills, spells, recipes, gathering nodes, loadouts). The CDO + DataAsset split keeps the runtime class minimal while letting designers add a new monster as an .uasset edit. Lazy registry is mandatory per ARCH #14 (PIE cold-start asset-registry race).
Implementation surface:
- File: Source/CRADL/Enemy/EnemyDefinition.{h,cpp} (new).
- Fields (authoring shape):
- FText DisplayName — kill-log / corpse label / interactable source label.
- TSoftObjectPtr<UTexture2D> Icon — bestiary / kill-log row.
- FGameplayTag FamilyTag — Enemy.Family.* (Goblin, Skeleton, Beast, etc.). Used by drop-table sub-table lookups and future content tagging.
- EEnemyHostility Hostility — Aggressive or Passive (see Hostility).
- float AggroRadiusCm — sphere-overlap radius the BT perception service queries.
- int32 LevelGate — for Aggressive: if player.CombatLevel >= LevelGate, the enemy no longer auto-engages this player (OSRS auto-aggro fade). For Passive, ignored.
- bool bRequireLOS — perception trace required (default true).
- int32 MaxAttackers — overrides the combat contract's default cap of 3. Bosses set higher; trash mobs may set lower.
- EEnemyPatrolMode PatrolMode — Stationary, RandomRadius, Spline (see Patrol). For Spline, the spawner provides the actual spline reference; the definition only declares "this enemy type follows a route if one is provided, else stationary."
- float PatrolRadiusCm — for RandomRadius; ignored otherwise.
- TArray<FWeightedEnemyVariant> Variants — spawn-time roll source (see Variants).
- TArray<FEnemyStatTuning> StatTuning — per-attribute Min..Max ranges rolled at spawn (see Stat Tuning).
- TSoftObjectPtr<UDropTableDefinition> DropTable — rolled on death (see Drop Tables).
- TArray<TSubclassOf<UCradlGameplayAbility>> GrantedAbilities — abilities the pawn's ASC receives on spawn. Default contains UAutoAttackAbility; an enemy that casts adds UCastSpellAbility; a custom-behavior enemy adds whatever bespoke ability it authors.
- TSoftClassPtr<UBehaviorTree> BehaviorTree — overrides the default BT for this enemy archetype.
- Registry: UEnemyRegistry : UGameInstanceSubsystem with lazy EnsureBuilt(), same shape as Source/CRADL/Combat/SpellRegistry.h. GetDefinitionByName(FName) and GetAllDefinitions(TArray<...>&).
- GetPrimaryAssetId() override returns FPrimaryAssetId("EnemyDefinition", GetFName()).
- Validator: UCradlEnemyDefinitionValidator under Source/CRADLEditor/Validators/ — checks DisplayName non-empty, FamilyTag under Enemy.Family.*, every variant's WeaponItemId resolves through UItemRegistry, every stat-tuning attribute is a real FGameplayAttribute, DropTable soft-ref resolves, BT soft-class resolves. Update lockstep with this definition per CLAUDE.md "Editor-time validators shadow the runtime structs."
Apply-at-spawn flow (AEnemyCharacter::ApplyDefinition, server only):
1. Cache ActiveDefinition (replicated to all peers via standard replication so cosmetic listeners can find the icon / display name).
2. Roll the variant (see Variants); cache ActiveVariantIndex (replicated int8).
3. Apply the variant's AnimBP onto ACharacter::Mesh and broadcast OnVariantChanged — UEnemyVisualsComponent spawns the variant's VisualsActor locally on every peer (cosmetic; rebuilt independently from the replicated variant index).
4. Stamp the variant's WeaponItemId into the enemy's UEquipmentComponent MainHand slot (and Ammo slot if ranged).
5. Roll each FEnemyStatTuning entry; build a single transient Infinite GE spec carrying the rolled values and apply to self (UGE_Enemy_InnateStats). One GE per spawn; replicates via standard GAS attribute aggregation.
6. Grant each GrantedAbilities entry to the ASC.
7. Assign BehaviorTree to the controller; controller's OnPossess seeds the blackboard from the definition.
Footguns:
- ApplyDefinition must be server-only and run before the BT starts. The BT reads aggro radius / patrol mode from the blackboard; if the controller possesses before the definition lands, the first BT tick reads default values. The cleanest sequence is AEnemySpawner::SpawnDeferred → ApplyDefinition → FinishSpawning → AIController::OnPossess (which by default fires at FinishSpawning, so seed the BT inside OnPossess reading from the already-populated pawn fields).
- Replication of ActiveDefinition and ActiveVariantIndex is load-bearing for cosmetics. Remote clients re-run the variant's visual setup when the second of the two reps lands (the rep notify gate is ActiveDefinition && ActiveVariantIndex >= 0), so UEnemyVisualsComponent spawns the right AEnemyVisualsActor on every peer. Don't bake the visuals into the pawn class default and assume server-only is enough — clients need the index to pick the right rig.
- bRequireLOS = false is for "blind aggro" enemies (giant scorpions, the OSRS dust devil archetype). Default true. Misusing this is the single biggest pacing footgun.
- Don't add a bIsBoss flag. Boss-ness is a content concept (encounter design) not a definition concept. If boss-only mechanics emerge, they ride on FamilyTag or a sibling RankTag under Enemy.Rank.*, not a boolean. See Open Questions.
Related: ARCH #6 (PrimaryDataAsset for definitions), ARCH #14 (lazy registry pattern), Stat Tuning, Variants, Drop Tables.
Variants
Rule: A UEnemyDefinition carries TArray<FWeightedEnemyVariant> Variants — inline structs, not a separate DataAsset. The server rolls one variant from the list (weighted) at spawn; the chosen index replicates to all peers as int8 ActiveVariantIndex. The variant supplies a TSoftClassPtr<AEnemyVisualsActor> cosmetic rig (spawned locally on every peer by UEnemyVisualsComponent), an optional stance AnimBP, the equipped weapon (as FName ItemId resolving against the items DataTable), and an optional per-variant stats GE. Variants are enemy-specific; they do not share data with ULoadoutDefinition (which is the player's loadout system).
Why: "Same monster, different weapon" is a permutation problem the loadout system could solve — the player's ULoadoutDefinition already bundles equipment slots, an innate stats GE, and a soft-loaded VisualsActor. But sharing that registry has costs: every player-facing loadout-picker UI / terminal would have to filter out enemy variants by tag, and the loadout system is built around player concepts (bag size, modifier slots, terminal interaction) that have no enemy meaning. A leaner inline struct stays simpler, doesn't pollute the player registry, and avoids "the Loadout Validator now has two flavors of validation" drift. The pattern (visuals + stats GE + equipment) is borrowed; the data type is not.
The visuals-actor surface is itself shared at the base-class level: AEnemyVisualsActor and ALoadoutVisualsActor both derive from AVisualsRigActor (a generic Blueprintable AActor host under Source/CRADL/Visuals/), and UEnemyVisualsComponent mirrors ULoadoutVisualsComponent — the binding source differs (variant index vs. loadout id) but the resolve/spawn/attach/teardown flow is identical, and both call through Cradl::Visuals::ResolveAnchor + IVisualsAnchorProvider for per-archetype anchor overrides.
The inline-struct decision (vs. a separate UEnemyVariantDefinition data asset) tracks the size of the surface — three-to-five fields per variant doesn't earn a sibling registry. If variants grow to carry abilities, AI overrides, dialogue, etc., promote later.
Implementation surface:
- Struct: FWeightedEnemyVariant
- FText DisplayName — overrides the definition's name on the kill-log when set (e.g. "Spear-wielding Goblin"); empty falls back to definition's DisplayName.
- int32 Weight — relative roll weight (default 1).
- FName WeaponItemId — resolves through UItemRegistry::FindItem against the items DataTable. The weapon's FItemRow brings WeaponDamageType, SwingInterval, AttackRange, StrengthBonus, AttackBonus[type], DefenseBonus[type] along for free.
- FName AmmoItemId — ranged variants only; populated into Item.Slot.Ammo. Server treats ammo as infinite for enemies (no per-shot consumption from a bag).
- TSoftClassPtr<AEnemyVisualsActor> VisualsActor — cosmetic rig spawned locally on every peer by UEnemyVisualsComponent. BP authors compose meshes / Niagara / decals / weapon mount points in the actor viewport, exactly like ULoadoutDefinition::VisualsActor for the player — piping a raw USkeletalMesh here would leave nowhere to author FX or secondary meshes. Optional — null means the character-class default mesh is the only visible body (single-variant archetypes that don't need a rig).
- TSoftClassPtr<UAnimInstance> AnimClass — for variants that need a stance-specific AnimBP (bow stance vs sword stance); applied to ACharacter::Mesh (the animation driver — the spawned VisualsActor attaches on top of it, not in place of it). Optional.
- TSubclassOf<UGameplayEffect> VariantStatsEffect — optional Infinite-duration GE applied alongside the rolled-stats GE. Adds variant-specific tuning (e.g. ranged variants get a longer leash distance via an attribute the BT reads).
- Roll site: AEnemyCharacter::ApplyDefinition performs the weighted roll server-side using a deterministic stream seeded from (SpawnerId, SpawnSequence) if reproducibility is wanted, or FMath::RandHelper otherwise. v1 = the latter; reproducibility is an Open Question.
- Replication: int8 ActiveVariantIndex is Replicated; whichever of OnRep_ActiveDefinition / OnRep_ActiveVariantIndex lands second (the two reps arrive in unspecified order) calls ApplyVariantCosmetics, which swaps the AnimBP onto ACharacter::Mesh and broadcasts FOnEnemyVariantChanged. UEnemyVisualsComponent listens to the delegate and async-loads the variant's VisualsActor class, spawning it locally on each peer with no replication of the spawned actor itself.
- Authoring rule: every definition has at least one variant. A single-variant enemy still uses the structure; the cost is one extra struct in authoring, the gain is "every enemy goes through the same code path."
Footguns:
- FName ItemId references the items DataTable, not a DataAsset. This is intentional: the project keeps FItemRow in Items.uasset (DataTable) as the lone authoring exception, and the drop / equipment / inventory pipelines already consume it that way. Following the existing convention also means the validator (UCradlItemTableValidator) catches missing rows; an enemy author who types a typo goblin_spaer gets surfaced by the enemy validator's items-DataTable resolution check at save time.
- Cosmetic-only state replicates the index, not the assets. Each peer async-loads the variant's VisualsActor class and AnimClass and spawns / applies them locally — don't replicate the resolved UClass* or the spawned AEnemyVisualsActor directly. The spawned rig actor sets bReplicates = false / bNetLoadOnClient = false for the same reason. Replicating either would defeat the soft-load pattern and inflate the per-pawn payload.
- Don't roll the variant client-side. Two peers rolling the same enemy independently produces a Goblin holding a spear on one machine and a sword on the other. Server rolls, server replicates the index, clients apply.
- Variant weight is not a probability. It's a relative weight against the other variants in the array; the roll is Sum(weights) * Rand and walk. Author "twice as common" by setting weight 2 against 1, not 0.66 / 0.33 — fractional weights work but readability suffers.
- The variant's stats GE composes with the definition's stat-tuning GE, it doesn't replace it. Both are applied on spawn; both layer through standard GAS aggregation. If a variant needs to override a tuning value, it does so via the stats GE additive/multiplicative modifier on the same attribute — there is no "remove tuning entry X" surface.
Related: Enemy Definition, Stat Tuning, COMBAT_SYSTEM.md "Equipment Combat Data".
Stat Tuning
Rule: A UEnemyDefinition carries TArray<FEnemyStatTuning> — one entry per attribute the designer wants to shape (Strength, Defense, MaxHealth, AttackSpeed, etc.). Each entry is either a scalar or a [Min, Max] integer range. The server rolls every range once at spawn and bakes the result into a single Infinite-duration GE (UGE_Enemy_InnateStats) applied to the pawn's ASC. The roll persists for the pawn's lifetime — re-engagement does not re-roll.
MaxHealth is the special case. The base MaxHealth is not sourced from a StatTuning entry — AEnemyCharacter::ApplyDefinition writes MaxHealth's base = HitpointsLevel × UCradlCombatSettings::EnemyMaxHealthPerHitpointsLevel. That setting defaults to 1 (OSRS-faithful, so HitpointsLevel is the HP pool) and is the enemy analog of the player's MaxHealthPerHitpointsLevel (default 4 + optional curve, applied via SetMaxHealthForLevel). StatTuning, VariantStatsEffect, and InnateStatsEffects then compose ± variance on top through standard GAS aggregation. So a MaxHealth tuning entry is optional per-spawn variety, not the HP pool itself — the validator no longer requires one, and a definition with no MaxHealth tuning still spawns at its level-derived base. This keeps the displayed combat level (which reads HitpointsLevel) and the effective HP from drifting apart.
Why: Designer-tuned ranges produce per-instance variety ("this goblin hits a little harder than that one") without committing to per-swing variance, which would feel inconsistent under OSRS-style fixed-cadence combat and would require either prediction reconciliation or visible mid-fight stat changes. Rolling at spawn matches the OSRS mental model (every monster instance is uniquely tuned within its species' band) and is invisible to GAS prediction because the rolled GE is in place before any combat ability activates. The single-GE bake (vs. one-GE-per-attribute) is purely an efficiency choice — one spec, one application, one teardown on death.
Implementation surface:
- Struct: FEnemyStatTuning
- FGameplayAttribute Attribute — the target attribute (e.g. UCradlAttributeSet::GetStrengthBonusAttribute()).
- int32 Min — inclusive lower bound.
- int32 Max — inclusive upper bound. Min == Max is the scalar case.
- EGameplayModOp Op — Additive (default) or Multiplicative. Multiplicative interprets Min/Max as percent (×100).
- GE class: UGE_Enemy_InnateStats — Infinite duration, modifiers populated at runtime (not as default subobjects): one FGameplayModifierInfo per tuning entry, appended to a transient instance. Granted tag: Status.EnemyInnateStats (so the GE is locatable and removable by tag).
- Roll site: AEnemyCharacter::ApplyDefinition NewObjects a transient UGE_Enemy_InnateStats, rolls each entry's [Min,Max] via FRandomStream, appends one FGameplayModifierInfo per entry (the entry's Attribute + Op, magnitude = FScalableFloat(rolled)) to its Modifiers array, and applies it to self via ASC->ApplyGameplayEffectToSelf. The instance is held in a UPROPERTY (PerSpawnInnateStatsGE) so the GC keeps it while the active GE references it.
- Validator: ensure Min ≤ Max, attribute is non-empty, attribute resolves on UCradlAttributeSet (no typo'd attribute names).
No player-level scaling. A level-10 Goblin is always a level-10 Goblin, regardless of who engages it. The stat-tuning roll consults the definition's [Min, Max] ranges and an RNG — never the engaging player's combat level, party composition, or session activity. This is an OSRS-faithful deliberate non-decision: scaling collapses the "outgrow zones and progress to harder content" loop into "everything is always your level," which is a different game shape than the project is committing to. The bored-late-game-player problem is solved by content design (harder zones, Slayer-style task variety, the OSRS Wilderness analog) — not by re-tuning existing enemies. If scaling is ever wanted, it would land as a sibling system that picks a different UEnemyDefinition at spawn time based on context, not as a modification to this roll.
Footguns:
- Don't roll into BaseValue directly. Writing to attribute BaseValues bypasses GAS aggregation; any future GE that wants to layer on top (a poison effect that drops Strength, a rage buff that raises it) would composes against a pre-modified base and feel wrong. Bake the roll into the GE's modifier magnitude, not the attribute itself.
- Per-swing re-roll is the wrong shape if you want widened damage. "Damage feels random per hit" is already provided by the [0, maxHit] integer damage roll in UCradlCombatMath::RollDamage. If a designer wants more variance, raise maxHit (via StrengthBonus), don't add per-swing stat-roll. Stat-tuning is per-spawn identity, not per-hit variance.
- Don't read the engaging player's combat level inside the roll. The "no scaling" rule above is load-bearing on the roll being pure-RNG over [Min, Max]. A roll that consults the player turns "this is a level-10 Goblin" into "this is a goblin whose level depends on who's looking" — a different contract.
- Reproducibility is not a v1 promise. Two players in a P2P session see the same enemy with the same rolled stats because the server is authoritative — but a save-and-reload that respawns the same enemy will pick fresh rolls. Deterministic-seed reproducibility (for repro replays, deterministic testing) is in Open Questions.
Related: Enemy Definition, Variants, STAT_PIPELINE.md.
Hostility
Rule: Each UEnemyDefinition declares EEnemyHostility Hostility ∈ {Aggressive, Passive}. All enemies retaliate when struck — the Passive flag governs first-strike, not whether the enemy ever fights back. Aggressive enemies engage any player within AggroRadiusCm (subject to LOS if bRequireLOS) whose CombatLevel < LevelGate. Passive enemies never auto-engage; they sit until attacked, then engage normally. An enemy that has begun engaging continues until standard combat-end conditions (COMBAT_SYSTEM.md Engagement Loop) fire.
Why: Mirrors OSRS's level-gated auto-aggro: trash mobs stop pestering high-level players ("low-level Goblins ignore level-30+"), but anything you provoke fights back. Splitting "auto-engage" from "retaliate" lets the doc cover both the "wandering peaceful cow you can attack" archetype and the "wandering Goblin that picks fights" archetype with the same data shape. The level-gate is per-enemy because the auto-aggro fade point differs per species in OSRS (a higher-level monster has a higher fade point); putting it on the definition keeps that authoring lever in one place.
Implementation surface:
- Enum: EEnemyHostility { Aggressive, Passive } on Source/CRADL/Enemy/EnemyDefinition.h.
- Definition fields: Hostility, AggroRadiusCm, LevelGate, bRequireLOS (all listed in Enemy Definition).
- BT decorator: BTDecorator_ShouldAutoEngage — returns true iff Hostility == Aggressive AND a player is in AggroRadiusCm AND (!bRequireLOS OR LOS clear) AND player.CombatLevel < LevelGate. The decorator's leaf is the engage-dispatch task.
- Retaliation: a separate BT branch fires on Combat.Event.Hit against the enemy's ASC (i.e. we were hit). The branch sets BlackboardKey:ActiveTarget = Instigator and falls into the same engage-dispatch task — Passive and Aggressive share that branch.
- Combat-level scalar: a free function CradlCombat::ComputeCombatLevel(const ACradlPlayerState* PS) returning int32. v1 stub: floor((Melee + Ranged + Magic) / 3) + floor(Defense / 2) + floor(Hitpoints / 4). Exact formula is an open question; the surface (one function, one home) is what's load-bearing.
Footguns:
- Retaliation must read Instigator from the damage event, not from the latest ActiveTarget. A multi-attacker pawn that re-targets must re-target to the most recent striker, not to whichever player happened to be the current ActiveTarget when the new hit landed.
- Don't make Passive mean "can't be attacked." Passive cows / chickens are legitimately killable for the drops and the XP. The flag gates the enemy's first-strike behavior; the player's IInteractable::CanInteract path is unchanged.
- LevelGate is per-player. Different players in the same area see different aggro behavior from the same mob. The BT decorator queries the perception target's CombatLevel, not a globally-stored value on the pawn.
- Don't add a "hates this player" memory. OSRS-style aggro has no grudge memory; once you leave the radius (or the LOS lapses + a leash timeout, see Open Questions) the enemy disengages. Adding a per-player threat list is a downstream design choice and not v1.
Related: AI Brain, Combat Level Scalar, Group Aggro, COMBAT_SYSTEM.md "In-Combat State".
Combat Level Scalar
Rule: A single canonical scalar CradlCombat::ComputeCombatLevel(Melee, Ranged, Magic, Defense, Hitpoints) computes a combatant's combat level from their five combat skills. Every reader funnels into it: a const ICombatStatsProvider* overload reads the 5 Skill.Combat.* leaves off either end of an engagement (player USkillsComponent, enemy authored levels, dummy), and a const ACradlPlayerState* convenience overload reads the player's skills. Enemies therefore get a derived combat level from the same formula — they author all five levels (the four offensive/defensive + HitpointsLevel) but store no combat-level field. The formula is OSRS-adapted to CRADL's 5-skill model (no Prayer, no Attack/Strength split):
int32 CradlCombat::ComputeCombatLevel(const ACradlPlayerState* PS) {
const int32 Defense = PS->GetSkills()->GetLevel(GameplayTags::Skill_Combat_Defense);
const int32 Hitpoints = PS->GetSkills()->GetLevel(GameplayTags::Skill_Combat_Hitpoints);
const int32 BestStyle = FMath::Max3(
PS->GetSkills()->GetLevel(GameplayTags::Skill_Combat_Melee),
PS->GetSkills()->GetLevel(GameplayTags::Skill_Combat_Ranged),
PS->GetSkills()->GetLevel(GameplayTags::Skill_Combat_Magic));
const int32 BaseTier = (Defense + Hitpoints) / 4;
const int32 StyleTier = (BestStyle * 13) / 40; // ~0.325, OSRS weighting
return FMath::Max(1, BaseTier + StyleTier + 3);
}
Why: OSRS's formula has decades of balance tuning behind it and is well-understood by the genre's audience. The adaptation preserves its three load-bearing properties: (1) max() over styles rewards leaning into one combat style (a pure-melee player and a pure-magic player at the same skill levels read as the same combat level); (2) Defense + Hitpoints as a passive base means a tank build still gains combat level even without offensive skilling; (3) the 13/40 ≈ 0.325 weighting keeps style-tier and base-tier roughly co-equal in contribution at high levels. Codifying the formula in one function makes it a stable reference point that UEnemyDefinition::LevelGate authoring numbers and any future combat-level-aware system (XP-tier badges, matchmaking, dungeon-entry gates) all share. Future numeric tuning replaces the constants, not the function shape — LevelGate numbers across the entire enemy library are interpreted in whatever units this function produces.
Reference numbers for authoring intuition (CRADL Hitpoints starts at 1, not OSRS's 10):
- Fresh character (all 1s): (1+1)/4 + 1*13/40 + 3 = 0 + 0 + 3 = 3
- Mid-game pure melee (Melee 50, Def 30, HP 30, others 1): (30+30)/4 + 50*13/40 + 3 = 15 + 16 + 3 = 34
- Maxed pure melee (Melee 99, Def 99, HP 99, others 1): (99+99)/4 + 99*13/40 + 3 = 49 + 32 + 3 = 84
- Maxed hybrid (all combat skills 99): same as maxed pure melee — max() picks one. Hybrid versatility is rewarded by capability, not by combat level.
Implementation surface:
- Functions (all in the CradlCombat namespace, sibling to the engagement-range resolver): the pure ComputeCombatLevel(Melee, Ranged, Magic, Defense, Hitpoints) scalar, a ComputeCombatLevel(const ICombatStatsProvider*) overload (reads the 5 leaves off either combatant), and the ComputeCombatLevel(const ACradlPlayerState*) convenience overload. Pure reads; no caching at v1 — integer level reads called at hostility-evaluation cadence (BT perception service tick) and on context-menu open, not per-frame. If it becomes a hot path later, cache on OnSkillLevelUp deltas.
- Risk appraisal: CradlCombat::AppraiseTarget(ViewerCL, TargetCL) buckets the level delta into an ECombatRiskTier (Trivial→Deadly) via UCradlCombatSettings::CombatRiskBands, returning an FCombatTargetAppraisal (level + delta + tier). It carries no color — the client maps the tier to a palette slot (ECradlTextColor) so theme/accessibility stays a client choice. AEnemyCharacter::GatherActions calls it (it has the interacting pawn), appends (level-N) to the menu source label, and sets the entry's SourceLabelColor.
- Callers: BTDecorator_ShouldAutoEngage + BTService_EnemyPerception (hostility level-gate check), AEnemyCharacter::GatherActions (right-click risk token). Future: bestiary "you could engage this" widget, hover-nameplate (reads AppraiseTarget directly), matchmaking.
Footguns:
- One canonical home. Don't re-derive combat level inline at call sites — even a single open-coded copy invites drift the next time the formula is tuned. Always funnel through CradlCombat::ComputeCombatLevel.
- HitpointsLevel is a display/identity level, not effective health. It exists so the formula has its 5th input and the enemy reads a stable per-archetype combat level. The enemy's actual survivability is the MaxHealth attribute (rolled per spawn via StatTuning / VariantStatsEffect / InnateStatsEffects) and varies ±without moving the displayed level — exactly like an OSRS monster whose listed combat level is fixed while its real HP differs. Don't try to derive one from the other.
- The relational risk color can't come from GetSourceLabel() — that getter has no pawn, and risk is TargetCL − ViewerCL. Compute it where the viewer is known (GatherActions), and keep the color a client-resolved token, never a baked FLinearColor.
- Don't read individual style scores at gate sites. "This enemy aggros melee players only" is a different feature (style-typed hostility); don't backdoor it by inspecting BestStyle's underlying skill inside the gate. If style-typed aggro is ever wanted, add it as an explicit field on UEnemyDefinition.
Related: Hostility, COMBAT_SYSTEM.md "Combat Skills".
Group Aggro
Rule: An enemy opts in to group aggro by setting UEnemyDefinition::GroupAggroRadiusCm > 0 (default 0 = solo aggro, no broadcast). When such an enemy fires its engage branch, a BT task sphere-overlaps for same-FamilyTag enemies within the radius and writes the engaged target to each neighbor's blackboard ActiveTarget. Receivers fall into the standard engage branch — same as if their own perception had detected the target. One hop only: a neighbor that received a broadcast does not re-broadcast.
Why: Most OSRS mobs are solo-aggro; packs are explicitly authored archetypes (cave goblins, dagannoth families). Opt-in via a default-zero radius matches that reality and keeps authoring intent explicit — a designer setting GroupAggroRadiusCm = 800 is making a deliberate "this is a pack monster" choice, not opting out of a noisy default. The single-hop rule prevents flicker cascades where A broadcasts to B, B broadcasts back to A, etc., and also keeps the encounter shape predictable (a pack of 4 visible enemies aggros together; an unseen 5th over a ridge doesn't get pulled in by chain-broadcast).
Implementation surface:
- Field: UEnemyDefinition::GroupAggroRadiusCm (float, default 0).
- BT task: BTTask_BroadcastGroupAggro at Source/CRADL/Enemy/BT/BTTask_BroadcastGroupAggro.{h,cpp} — fires from the engage branch immediately after ActiveTarget is set. Sphere-overlap against APawns, filtered to AEnemyCharacters with matching ActiveDefinition->FamilyTag, excluding self. For each match: write ActiveTarget into the neighbor's blackboard and mark a bAggroBroadcastReceived flag (consumed in the next decorator check to prevent re-broadcast on the same engagement).
- Receiver guard: BTDecorator_ShouldAutoEngage checks the broadcast flag in addition to its perception logic — a broadcast receiver engages without needing to satisfy the perception radius, but still honors Hostility and LevelGate against the broadcast target. A level-faded Goblin doesn't get pulled into a fight just because its packmate started one.
Footguns:
- Group aggro does not bypass the multi-attacker cap. A broadcast that pushes a target above its MaxAttackers results in the receiver's authoritative TryRegisterAttacker failing on first swing; the receiver gets the standard "target is overwhelmed" feedback path and falls back to patrol. Don't pre-filter on the broadcast side — let the existing combat machinery do its job.
- Family-tag match is exact-leaf, not parent. A Enemy.Family.Goblin.Wizard broadcast does not pull in Enemy.Family.Goblin.Warrior neighbors. If hierarchical aggro is ever wanted, the comparison becomes MatchesTag (parent-OK) — but the default exact-match is the right v1 shape because it forces designers to think about which pack is aggroing.
- One-hop is enforced via a blackboard flag, not by tracking broadcast graph state. Simpler than maintaining a "who broadcast to whom this turn" set; the cost is that a receiver who's also an aggressive-perception aggro source can't then broadcast outward on the same turn. That's fine — the original broadcaster already covered the radius.
- Don't add group aggro to Passive enemies. A passive enemy doesn't fire the engage branch from perception; broadcasting on retaliation only would change the contract ("hit one cow and the whole herd attacks") in an unexpected way. Group aggro is for Aggressive enemies only — enforced by BTTask_BroadcastGroupAggro being on the perception-driven engage branch, not the retaliation branch.
Related: Hostility, AI Brain, COMBAT_SYSTEM.md "Multi-Attacker Ceiling".
Patrol
Rule: Three patrol modes selectable per UEnemyDefinition::PatrolMode:
- Stationary — enemy stands at its spawn point; no idle movement.
- RandomRadius — enemy wanders to random points within PatrolRadiusCm of its spawn anchor; idle delay between waypoints.
- Spline — enemy follows a USplineComponent route provided by its spawner; loops or ping-pongs.
Patrol movement issues through IMoveIssuer::IssueMoveTo(FVector) — not through the chase primitive. Chase is combat-flavored (closing on a moving target with re-issue rate-limiting); patrol is plain "walk to this spot."
Why: Splitting patrol movement from chase movement keeps FCradlChaseState focused on its actual concern (closing distance under combat conditions) and keeps the patrol BT branch simple — a single MoveTo task with arrival check, no rate-limiting machinery. The cost is two movement code paths instead of one; the benefit is each path stays the right size for what it does. Per-mode policy on the definition (vs. inferring from spawner shape) means an enemy archetype's patrol intent is declarative — "Goblins wander" is one field, not a spawner-by-spawner repetition.
Implementation surface:
- BT tasks: BTTask_PatrolStationary (no-op), BTTask_PatrolRandom (pick point in radius → MoveTo), BTTask_PatrolSpline (advance spline distance → MoveTo).
- Spline route: AEnemySpawner::PatrolSpline is an optional USplineComponent on the spawner; the spawner stamps a reference onto the spawned pawn's EnemyAIController blackboard. Definition's PatrolMode = Spline requires the spawner to provide one; absence falls back to Stationary with a warning log.
- Idle delays: BlackboardKey:PatrolIdleSeconds — small randomized delay between waypoints so patrols don't feel mechanical.
Footguns:
- Patrol cancels on aggro, not the reverse. When the auto-engage branch fires, the patrol branch is preempted; when engagement ends (target dead, leash decorator abort, cancellation tag) the BT falls back to patrol. Don't try to "patrol while attacking" — it's confused movement intent.
- Spline ownership is on the spawner, not the pawn. Many pawns can share one spline (patrol path with two patrolling Goblins). Putting the spline on the pawn would force one-per-pawn authoring.
- Don't drive patrol through chase. Calling IPawnCombatant::BeginChase against a "patrol target actor" reads to future readers as combat intent and ties patrol into the no-internal-give-up chase contract — a patrolling pawn with no leash decorator would pursue its patrol target forever instead of just walking to a spot and stopping.
Related: AI Brain, Leashing, COMBAT_SYSTEM.md "Chase & Movement".
Leashing
Rule: Each UEnemyDefinition declares LeashRadiusCm (default 1500; 0 = disabled, for bosses or designated rangers). On spawn, the pawn captures AnchorLocation — for Stationary / RandomRadius patrol this is a fixed point (the spawner location, or the editor-placed pawn's initial location); for Spline patrol the anchor is dynamic and tracks the closest point on the spline. While engaged, a BT decorator aborts the engage branch when distance(self, anchor) > LeashRadiusCm. On abort, UGE_Enemy_Leashing is applied and the BT runs BTTask_LeashReset — a latent task that forces the pawn to retreat all the way back to the anchor location (not just back inside the leash radius), then heals to full HP via an instant GE and clears the leashing tag. An optional AEnemySpawner::LeashRadiusOverrideCm (with bOverrideLeashRadius companion flag) wins over the definition's value for per-encounter tuning.
Why: The chase primitive has no internal give-up — IPawnCombatant::BeginChase pursues indefinitely. Leashing is the only mechanism that ends AI pursuit: without it, a player can kite a Goblin across the entire map and the Goblin will follow forever. With leashing, the enemy disengages predictably and resets, matching OSRS combat economy. Per-enemy default lives on the definition because leash behavior is an archetype property (Goblins are scrappy and chase short; dragons hold territory and chase long); the optional spawner override exists for the inevitable "boss arena Goblin needs to stay in the arena" case without forcing every spawner to re-state the field. Heal-on-return matches the OSRS contract that surviving the kite costs the player the kill — a half-killed enemy that leashed away rolls back to full and you start over.
Implementation surface:
- Fields on UEnemyDefinition:
- float LeashRadiusCm = 1500.f — distance from anchor at which engagement aborts. 0 disables leashing entirely (boss / kiting-ranged archetype).
- Fields on AEnemySpawner:
- bool bOverrideLeashRadius = false
- float LeashRadiusOverrideCm = 1500.f
- When bOverrideLeashRadius is true, the spawned pawn caches the override value; otherwise it caches the definition's value. Resolved once at spawn — runtime changes to either field don't propagate to live pawns.
- Server-only pawn state: AEnemyCharacter::AnchorLocation (FVector), AEnemyCharacter::EffectiveLeashRadiusCm (float, baked at spawn). Spline-patrol pawns recompute AnchorLocation each BT perception tick as the closest point on the spline within a clamped distance from current position.
- BT decorator: BTDecorator_WithinLeash at Source/CRADL/Enemy/BT/BTDecorator_WithinLeash.{h,cpp} — returns false when distance(pawn, anchor) > effective radius. Decorator's bAbortOwnLogic = true so a leash break interrupts the engage branch immediately, not on next-tick. On abort, UBTTask_WaitForEngagementEnd::AbortTask applies UGE_Enemy_Leashing to mark the pawn as retreating.
- Reset GE: UGE_Enemy_HealToFull — instant duration, modifier sets Health = MaxHealth via an override modifier. Lives in Source/CRADL/Combat/CombatGameplayEffects.h alongside the other combat GEs.
- Reset task: BTTask_LeashReset at Source/CRADL/Enemy/BT/BTTask_LeashReset.h — drops as a sibling of the patrol tasks in the Idle Sequence (first, so retreat preempts patrol while Status.Leashing is set). Gates: leashing-tag absent → instant Succeeded (lets patrol siblings run on the common case); dead → Failed; otherwise issues IssueMoveTo(AnchorLocation) and ticks until the pawn arrives within ArrivalToleranceCm (default 50cm, tight — roughly a character capsule's width) or MaxWalkSeconds (default 20s watchdog) elapses. On arrival: heal-to-full, clear UEnemyDropComponent attribution, remove UGE_Enemy_Leashing. On stall: stop move, Succeed so the BT loops and retries on the next iteration. ArrivalToleranceCm is a per-task UPROPERTY (Cradl|Leash category) — it's set on the BT asset, not per-enemy-archetype; archetypes that want different tolerances need their own BT.
- Disengage path: leash abort fires ASC->CancelAbilities({Action.Combat.AutoAttack}) plus IPawnCombatant::EndChase (centralized in UBTTask_WaitForEngagementEnd::AbortTask) — the same teardown shape combat cancellation already documents. Status.InCombat clears via its natural 20s expiry (no special path).
- Validator: LeashRadiusCm >= 0 is enforced via ClampMin="0.0" on the UPROPERTY. No further leash-related rules.
Footguns:
- Don't fire the heal GE until the pawn has actually returned to the anchor. Healing on leash abort (or on re-entry to the leash radius) rewards kiting — a player can damage→leash→heal in a tight cycle at the leash boundary, netting zero progress per attempt. Forcing the retreat all the way to the anchor commits real return-trip time before re-engageability. The "boundary re-entry" alternative was considered and rejected for this reason; see BTTask_LeashReset.h header. Single lever stays LeashRadiusCm: wider leash → longer retreat → fewer kite cycles per minute.
- Per-archetype heal-tolerance tuning is not supported. ArrivalToleranceCm lives on the BT task asset (BTTask_LeashReset), not on UEnemyDefinition. Every enemy that shares a behavior tree shares the same arrival tolerance. If a future archetype legitimately needs a different tolerance (e.g. a giant whose capsule is wider than 50cm), the options are (a) a dedicated BT for that archetype, or (b) lift ArrivalToleranceCm onto UEnemyDefinition and have the task read it through GetOwningEnemy(). The latter is the cleaner long-term shape but isn't worth the wiring until a second archetype actually needs the divergence.
- Heal-to-full is an override modifier, not additive damage. Writing Health = MaxHealth via an instant GE goes through PostGameplayEffectExecute like any other attribute write — but it does not route through the Damage meta, so it doesn't fire heal cues, doesn't refresh Status.InCombat, and doesn't grant Hitpoints XP to anyone. This is intentional: leash-heal is a state reset, not a healing event in the gameplay sense.
- LeashRadiusCm = 0 means no leash, not "leash at zero distance." Sentinel value. A decorator that reads 0 and computes distance > 0 would fire on the first tick after spawn. The decorator short-circuits when the effective radius is 0.
- Don't put the anchor on UEnemyDefinition. The definition is shared across every spawn of that enemy; the anchor is per-instance state. Put it on the pawn.
- Spline-patrol anchor updates must be clamped. If "closest point on spline" is allowed to jump arbitrary distances per perception tick, a pawn could effectively chase a target indefinitely along a long spline. Clamp the per-tick anchor advance to a reasonable rate (e.g. ≤ patrol walk speed * tick interval), so leashing still bites when the pawn is dragged off the spline by a chase.
- Leashing does not erase loot attribution. A pawn that leashed away and reset still carries its UEnemyDropComponent damage map — but in practice the pawn is at full HP now, so the existing damage totals are irrelevant to the next death. Server-side reset: clear the damage map when the heal GE applies so a re-engagement starts from a clean attribution slate. Without this, a player who damaged the enemy through one leash cycle and then another player killed it would still be tagger by old damage.
Related: Patrol, AI Brain, Loot Attribution, COMBAT_SYSTEM.md "Chase & Movement".
Drop Tables
Rule: UDropTableDefinition : UPrimaryDataAsset is the authoring surface for what a dead enemy yields. An enemy references a single drop table; drop tables compose — a definition's SubTables array carries soft-refs to other drop tables that roll as additional independent picks. Entries resolve to FName ItemId against the items DataTable (the lone DataTable holdout — drop entries are pure references, never inline item data). Lazy UDropTableRegistry : UGameInstanceSubsystem enumerates them per ARCH #14.
Why: A separate registry keyed by drop table (rather than per-enemy embedded drops) collapses authoring duplication for shared loot — OSRS's "Rare Drop Table" referenced from dozens of monsters is the canonical example. Composition lets a Goblin reference DT_Goblin_Specific plus a shared DT_RareDropTable plus a shared DT_Bones_Always without copy-pasting entries across thirty UEnemyDefinition files. Resolving entries through FName ItemId against the items DataTable preserves the project's existing item-data truth source (the same path FItemDrop already uses on UGatheringNodeDefinition); the drop table is metadata about items, not a parallel item registry.
DataAsset (not DataTable) for the drop table itself: every other definition in this project is a UPrimaryDataAsset with a lazy registry, and drop tables benefit from soft-pointer composition that's awkward in DataTables. The items table stays the items table; drop tables go where every other definition goes.
Implementation surface:
- File: Source/CRADL/Loot/DropTableDefinition.{h,cpp} (new).
- Definition fields:
- FText DisplayName — bestiary / debug visibility.
- TArray<FDropTableEntry> AlwaysDrops — every entry rolls every kill (bones, ashes). Count uses Min/Max per entry.
- TArray<FDropTableEntry> WeightedDrops — one pick from this array per kill, weighted by entry weight. Entries can be empty (i.e. "no drop") by setting an empty ItemId — the OSRS "nothing" slot.
- TArray<TSoftObjectPtr<UDropTableDefinition>> SubTables — each sub-table rolls independently (its own weighted-pick + always-drops); used to nest the Rare Drop Table.
- Entry struct: FDropTableEntry
- FName ItemId — resolves through UItemRegistry::FindItem. Empty ItemId on a weighted entry is the "no drop" slot.
- int32 Weight — relative weight within WeightedDrops. Ignored in AlwaysDrops.
- int32 MinCount, MaxCount — uniform integer count roll.
- TArray<FSkillRequirement> SkillRequirements — optional eligibility gate (mirror of FItemDrop::SkillRequirements on UGatheringNodeDefinition). v1 use-case: skill-locked rare drops.
- Roll function: CradlLoot::RollDropTable(const UDropTableDefinition*, const FDropAttribution&) — pure server-side; returns TArray<FRolledItem>. Recurses into SubTables (with cycle detection via a visited-set passed down).
- Registry: UDropTableRegistry : UGameInstanceSubsystem, lazy EnsureBuilt, GetTable(FName) / GetAllTables(...).
- GetPrimaryAssetId() returns FPrimaryAssetId("DropTableDefinition", GetFName()).
- Validator: UCradlDropTableDefinitionValidator — every FName ItemId resolves through UItemRegistry; weights ≥ 0 (a 0-weight weighted entry is allowed but warned — it's a no-op); SubTables resolve; sub-table cycles fail with a clear message.
Footguns:
- The "no drop" slot is an empty ItemId, not a bIsNoDrop flag. Weighted picks treat empty ItemIds as a successful roll-with-no-output. This matches OSRS authoring intent ("33% to drop, 67% to drop nothing"); a parallel flag would invite forks where one site checks the flag and another only checks the ItemId.
- Cycle detection is mandatory. DT_A referencing DT_B referencing DT_A is a finite-but-explosive recursion. The roll function passes a visited-set through recursive calls; cycle detection at validation time catches it before runtime.
- Skill-requirement gates use the attacker's skills. Drop-table eligibility queries the loot-attributed player (see Loot Attribution), not the enemy. A "requires Slayer 50" rare drop only rolls for an attacker who meets it.
- Don't read items table values inside the drop roll. The roll produces FName ItemId + count; ground-item spawn (a downstream concern) resolves the row. Mixing roll and resolve forces the loot module to depend on the items module's row shape and makes drop-table validation more expensive than it needs to be.
- Sub-table rolls are independent. Each sub-table independently rolls its own AlwaysDrops + WeightedDrops. A monster with WeightedDrops + SubTable(RareDropTable) yields a primary weighted pick plus a rare-table weighted pick on every kill, not a single roll across the union. Matches OSRS semantics; document so designers don't expect mutual exclusion.
Related: Death, Loot Attribution, Source/CRADL/World/GatheringNodeDefinition.h (FItemDrop precedent).
Loot Attribution
Rule: When an enemy dies, the player who dealt the most cumulative damage (the "tagger") owns the drops for a configurable ownership window. Drops spawn as AGroundItem actors at the corpse's location with OwnedBy = tagger; during the window, only the tagger can pick them up. After the window, ownership clears and the items become free-for-all. A server-only UEnemyDropComponent on each AEnemyCharacter tracks TMap<TWeakObjectPtr<ACradlPlayerState>, int32> DamageDealt.
Why: Most-damage wins is the OSRS-canonical rule and matches the genre audience's expectation. Last-hit alternatives reward "stealing kills" with low-effort hits; most-damage rewards the player who actually fought the engagement. The ownership window (rather than permanent attribution) prevents grief patterns (kill a mob and disconnect, denying the drops indefinitely) while preserving the tag's intent.
Implementation surface:
- Component: UEnemyDropComponent on AEnemyCharacter, server-only (no replication — clients don't need attribution state).
- Damage hook: UCradlAttributeSet::PostGameplayEffectExecute writes += DamageDelta into EnemyDropComponent::DamageDealt[Attacker->GetPlayerState()] when the victim is an enemy. The HP-XP grant path already resolves attacker → PlayerState on every successful hit; reuse that lookup.
- Death-time resolution: UEnemyDeathAbility::ActivateAbility (server) walks the DamageDealt map, picks the highest, builds an FDropAttribution { Tagger, ContributorCount }, calls CradlLoot::RollDropTable(definition->DropTable, attribution), and spawns one AGroundItem per rolled entry. The AGroundItem actor already supports per-player visibility / pickup gating in its existing flow (see the Drop Item ability); plumb OwnedBy = Tagger and OwnershipExpiresAt = Now + UCradlCombatSettings::DropOwnershipSeconds (default 60s) through the spawn parameters.
- Tie-breaking: if two attackers deal exactly equal damage, the most-recent striker wins. Edge case; doc the tiebreak so it's predictable.
Footguns:
- TWeakObjectPtr<ACradlPlayerState> clears on disconnect, which is what we want. A player who disconnects mid-fight forfeits their tag automatically; the next-highest remaining player becomes the tagger. Don't switch to FUniqueNetId "to remember disconnected players" — that re-opens the grief pattern.
- Track damage on the victim, not the attacker. Attribution is asked once per death and answered against one TMap; tracking on each attacker would require gathering state across N PlayerStates at resolution time. The TMap on the enemy is the natural home.
- Don't broadcast DamageDealt to clients. Tag state is server-only; clients see the drops only after the death ability spawns the ground items.
- Drop ownership window starts at corpse-spawn, not at kill-trigger. A trivially short edge case but worth being explicit — the timer begins when the ground items exist, not earlier.
Related: Drop Tables, Death, COMBAT_SYSTEM.md "Health & Damage Application".
Spawners
Rule: AEnemySpawner : AActor is a placeable, server-only spawner. It carries a soft-ref to a UEnemyDefinition, a spawn radius, a respawn delay, a max-alive cap, and an optional patrol spline. On BeginPlay (server) it spawns up to MaxAlive; each spawned enemy stamps a back-ref to its spawner so death-cleanup can schedule a respawn after RespawnSeconds. Spawners do not replicate; only the spawned pawns do.
Why: Editor-placed enemies (drop an AEnemyCharacter directly into the level) work for fixed-position bosses and target dummies but break down for the wandering-Goblin-population pattern. A spawner externalizes "where do these enemies come from" from the pawn itself, lets designers place one spawner instead of N enemies, and centralizes respawn cadence. Following the existing pattern of "placeable server-only actor manages a population" (no current direct analogue in the codebase, but the shape mirrors crafting / banking terminals which are placeable and own behavior).
Implementation surface:
- File: Source/CRADL/Enemy/EnemySpawner.{h,cpp} (new).
- Fields:
- TSoftObjectPtr<UEnemyDefinition> EnemyDefinition — what to spawn.
- float SpawnRadiusCm — random offset around the spawner's location for each spawn.
- int32 MaxAlive (default 1) — population cap. v1 default supports the single-spawn-point case.
- float RespawnSeconds (default 30) — delay between a death and the next spawn. Each slot has its own independent timer.
- USplineComponent* PatrolSpline (optional) — passed to spawned pawns whose PatrolMode = Spline.
- Spawn flow: BeginPlay (auth-only) → SpawnNext() until MaxAlive reached → SpawnNext() does SpawnActorDeferred<AEnemyCharacter> → ApplyDefinition → FinishSpawning → controller possesses → BT starts.
- Death notification: each spawned pawn stores TWeakObjectPtr<AEnemySpawner> OwningSpawner; UEnemyDeathAbility::EndAbility calls OwningSpawner->NotifyDeath() which sets a respawn timer.
- Editor visualization: a billboard with a per-definition icon (reuse engine's S_NavLink sprite or similar) so designers see spawner placement in-editor.
Footguns:
- Spawner is server-only — don't add bReplicates = true. Clients never need to see the spawner; they see only its outputs (the spawned pawns) which replicate normally.
- Don't share one EnemyDropComponent across spawner siblings. Each pawn has its own. The spawner doesn't aggregate drop state; it only owns spawn timing.
- MaxAlive > 1 with shared PatrolSpline is a common authoring shape (one path, multiple Goblins). v1 supports it; each pawn picks an offset along the spline at spawn so they don't pile up at waypoint 0.
- A spawner whose EnemyDefinition fails to resolve at runtime should fail loudly. Editor-time validator catches missing refs at save; runtime fallback is to log an error and skip spawning rather than silently producing nothing — the silent failure mode is the one that wastes an hour of "where are the goblins?" debugging.
Related: Enemy Definition, Death.
Death
Rule: An enemy's Combat.Event.Death fires UEnemyDeathAbility (separate class from the player's UDeathAbility). On activate (server only): apply Status.Dead, roll the drop table with the tagger as attribution, spawn ground items at the corpse, notify the spawner to schedule respawn, end the ability after a brief corpse-visible window (default 3s). The pawn is destroyed at the end of the corpse window. No cold storage — enemy death drops to the world.
Why: Player death and enemy death diverge on cleanup (cold storage vs ground drops, respawn-self vs notify-spawner-and-destroy), but share the trigger event and the Status.Dead GE application. Splitting into two ability classes (instead of branching one) keeps each ability's body focused and aligns with the rest of the codebase's "one ability, one verb" convention. The corpse-visible window gives players a moment to identify the kill and lets the death cue play without the visuals snapping out.
Implementation surface:
- File: Source/CRADL/Enemy/EnemyDeathAbility.{h,cpp} (new).
- Class: UEnemyDeathAbility : UCradlGameplayAbility. NetExecutionPolicy = ServerOnly (no client to predict for; enemies are server-owned).
- Trigger: Combat.Event.Death (same event the player death listens to). The granted-ability gate is the pawn class — AEnemyCharacter grants UEnemyDeathAbility, ACradlPlayerState grants UDeathAbility. Each only responds to its own ASC's event.
- ActivationOwnedTags = Action.Combat.Death (shared tag — matches existing combat-death convention).
- On activate (server):
1. Apply UGE_Status_Dead to self (granted tag Status.Dead, infinite).
2. Resolve Tagger from UEnemyDropComponent::ResolveTagger().
3. CradlLoot::RollDropTable(definition->DropTable, attribution) → TArray<FRolledItem>.
4. For each rolled item: World->SpawnActor<AGroundItem>(...) at the corpse location with OwnedBy = Tagger, OwnershipExpiresAt = Now + UCradlCombatSettings::DropOwnershipSeconds.
5. OwningSpawner->NotifyDeath() schedules the spawner's respawn timer.
6. WaitDelay(UCradlCombatSettings::EnemyCorpseSeconds) task — corpse-visible window.
7. On task complete: Pawn->Destroy(); EndAbility.
- Settings additions: UCradlCombatSettings::DropOwnershipSeconds (default 60), EnemyCorpseSeconds (default 3).
Footguns:
- Don't trigger respawn from EndPlay. The death ability is the authoritative cleanup site; using EndPlay would also fire on map-unload / level-streaming, scheduling phantom respawns. The spawner's NotifyDeath is called from the ability, exactly once per kill.
- Status.Dead is applied on the corpse but the pawn is about to be destroyed. Cosmetic listeners (HUD kill-feed, death cue) read off the Combat.Event.Death event payload at the moment of fire, not from the tag — the tag is internal bookkeeping. Per COMBAT_SYSTEM.md In-Combat State footgun, the rule "tag is the right source for 'is X true now?'" still holds — but the window where the enemy "is dead" is the corpse window, after which the pawn no longer exists.
- Ammo / weapon items in the variant do not drop unless the drop table explicitly lists them. The variant's WeaponItemId is the enemy's equipped weapon for combat-stat purposes; it is not automatically added to the drop pool. If a designer wants "kill the spear Goblin, get a chance at his spear," the spear is an entry in DT_Goblin_Specific.
- Don't extend UDeathAbility to "also handle enemy case." The combat contract specifies UDeathAbility for the player. Branching on IsA<AEnemyCharacter> inside a player-named class is the wrong scaling story; sibling classes are correct.
Related: Drop Tables, Loot Attribution, Spawners, COMBAT_SYSTEM.md "Death & Cold Storage" (player counterpart).
Authority & Replication
Rule: Enemies are spawned by the server (AEnemySpawner is auth-only) and server-owned. Each enemy pawn replicates to all peers via standard replication (bReplicates = true, bReplicateMovement = true). The ASC lives on the pawn and replicates via the engine's standard GAS plumbing (AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed) is a reasonable default, though Full works too — enemies have no predicting client to optimize for). All enemy abilities use NetExecutionPolicy = ServerOnly. Replicated cosmetic state on the pawn (ActiveDefinition, ActiveVariantIndex, ActiveTarget) drives client-side visuals via standard rep notifies.
Why: Enemies have no controlling client; nothing predicts on their behalf. ServerOnly is therefore the correct ability policy — LocalPredicted exists to mask network latency for an input-issuing client, which doesn't exist here. Standard bReplicateMovement covers position; standard GAS attribute replication covers Health / MaxHealth / AttackSpeed. The ActiveDefinition and ActiveVariantIndex replicate so cosmetic listeners (UEnemyVisualsComponent rig spawn, AnimBP swap, HUD nameplate) build the right visual on each peer.
Implementation surface:
- AEnemyCharacter::AEnemyCharacter sets bReplicates = true, default movement replication.
- UEnemyDefinition* ActiveDefinition — Replicated, no rep notify (read-only after spawn).
- int8 ActiveVariantIndex — ReplicatedUsing = OnRep_ActiveVariantIndex (drives cosmetic apply).
- AbilitySystem UPROPERTY uses EditAnywhere, BlueprintReadOnly, Category="GAS"; constructor sets bReplicates = true on the ASC.
- Enemy abilities (UEnemyDeathAbility, plus any future enemy-specific class) set NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerOnly in their constructors.
- The auto-attack split (UCradlAutoAttackAbilityBase + UAutoAttackAbility + UEnemyAutoAttackAbility, see Combat Reuse) inherits LocalPredicted from the base. On enemy ASCs with no owning client, the engine degrades execution to server-only — no per-flavor branch needed. Same path Lyra's AI-driven shared abilities take, but the per-actor persona lives in the subclass instead of branching inside one merged class.
Footguns:
- Don't try to make enemies LocalPredicted explicitly. There is no client to predict for; the prediction-window machinery overhead is paid for no benefit on enemy-specific abilities (UEnemyDeathAbility etc.). ServerOnly is the correct policy on any new enemy-specific ability that doesn't inherit from the auto-attack base.
- Shared engagement-loop core (UCradlAutoAttackAbilityBase) is shared via inheritance, not via one class doing both jobs. The math, cadence, cap registration, and damage GE all live on the base — so there's exactly one swing-roll code path under PIE. The driver subclass owns the per-actor persona (player UI / spellbook / XP routing vs. enemy targeting host / placeholder levels). Don't add if (PS) guards inside the base; per-actor branches go in the virtual hooks.
- Don't replicate UEnemyDropComponent's damage map. Clients don't need attribution state; replicating it inflates bandwidth and exposes per-attacker numbers that have no client-side consumer.
- MinimalReplicationTags vs Full. Mixed replication mode (default for many GAS setups) routes per-client GE replication only to the owning client + uses MinimalReplicationTags for spectators. Enemies have no owning client, so Mixed effectively becomes "MinimalReplicationTags for everyone" — which is what we want; spectators see the loose tags they need for AnimBP / HUD without per-GE fanout.
Related: COMBAT_SYSTEM.md "Net Execution Policy", Spawners.
Tag Taxonomy
Delta vs current Config/DefaultGameplayTags.ini. New top-level / sub-namespaces this doc claims:
Enemy.* — enemy-specific facts:
- Enemy.Family.* — species / type leaves: Enemy.Family.Goblin, Enemy.Family.Skeleton, etc. Used by drop-table sub-tables (a shared "all skeletons drop bones" sub-table can be selected by family tag) and future content tagging.
- Enemy.Hostility.* — Aggressive, Passive. The EEnemyHostility enum is the runtime source; the tag namespace is reserved if future GAS reads / debug filters want tag-based access. C++ declaration deferred until first use per the project's "declare in C++ only when referenced by name" rule.
- Enemy.Rank.* — reserved (Trash, Elite, Boss). .ini only; no C++ leaves yet.
Status.* additions:
- Status.EnemyInnateStats — granted tag for the UGE_Enemy_InnateStats GE. Used so the GE is locatable by tag for removal / introspection.
Action.Trigger.* additions: None. Enemies fire the existing Action.Trigger.Combat.Engage and Action.Trigger.Combat.Cast events.
Action.Combat.* additions: None. Enemies reuse Action.Combat.AutoAttack, Action.Combat.CastSpell, Action.Combat.Death (the death tag is shared between UDeathAbility and UEnemyDeathAbility — same verb, different sites).
Footgun: Per ARCH #15 and the COMBAT_SYSTEM tag footgun, parent-tag event subscription fires with the parent tag in the callback. A HUD that wants to read "which family am I fighting?" must subscribe per-leaf (or read UEnemyDefinition::FamilyTag directly off the active target), not subscribe to Enemy.Family as a parent.
Forward Code References
Names new code surfaces so future PRs land predictably. These are not implementation tasks for the doc itself; they're forward references the doc anchors.
New pawn class: AEnemyCharacter at Source/CRADL/Enemy/EnemyCharacter.{h,cpp}.
New controller class: AEnemyAIController : AAIController, public IMoveIssuer at Source/CRADL/Enemy/EnemyAIController.{h,cpp}.
New components on AEnemyCharacter:
- UChaseComponent (reused verbatim from Source/CRADL/Combat/ChaseComponent.h).
- UEnemyDropComponent (server-only damage attribution tracking) at Source/CRADL/Enemy/EnemyDropComponent.{h,cpp}.
- UEquipmentComponent (existing, slot list narrowed to MainHand + optional Ammo).
- UCombatTargetingComponent (existing class, hosted on the pawn instead of PlayerState — see Combat Reuse note on replication condition).
New definition assets:
- UEnemyDefinition at Source/CRADL/Enemy/EnemyDefinition.{h,cpp} + content at Content/Definitions/Enemies/.
- UDropTableDefinition at Source/CRADL/Loot/DropTableDefinition.{h,cpp} + content at Content/Definitions/DropTables/.
New registries (lazy UGameInstanceSubsystem per ARCH #14):
- UEnemyRegistry at Source/CRADL/Enemy/EnemyRegistry.{h,cpp}.
- UDropTableRegistry at Source/CRADL/Loot/DropTableRegistry.{h,cpp}.
New GE classes:
- UGE_Enemy_InnateStats — Infinite-duration; modifiers appended at runtime (one FGameplayModifierInfo per tuning entry on a transient instance), granted tag Status.EnemyInnateStats. Lives alongside the other combat GEs at Source/CRADL/Combat/CombatGameplayEffects.h.
- UGE_Enemy_HealToFull — instant duration, override modifier setting Health = MaxHealth. Sibling location. Fired by BTTask_LeashReset on anchor return (see Leashing).
New BT decorators / tasks under Source/CRADL/Enemy/BT/:
- BTDecorator_ShouldAutoEngage — perception + hostility + level-gate check (see Hostility).
- BTDecorator_WithinLeash — leash-distance check, aborts engage on violation (see Leashing).
- BTTask_FireCombatEngage — dispatches Action.Trigger.Combat.Engage against the pawn's ASC.
- BTTask_BroadcastGroupAggro — sphere-overlap same-FamilyTag neighbors and write ActiveTarget (see Group Aggro).
- BTTask_LeashReset — fires UGE_Enemy_HealToFull on anchor return + clears the pawn's UEnemyDropComponent damage map.
- BTTask_PatrolStationary / BTTask_PatrolRandom / BTTask_PatrolSpline — patrol mode tasks (see Patrol).
New abilities (auto-attack split):
- UCradlAutoAttackAbilityBase (abstract by convention) at Source/CRADL/Abilities/CradlAutoAttackAbilityBase.{h,cpp} — engagement-loop core (cadence, damage, cap, range, cue, hit/miss). Driver-specific decisions exposed as virtual hooks (SnapshotSwingStats, ResolveAttackerLevel, OnEngagementBegan/Ended, ConsumeAmmoForSwing, ConsumeRunesForSwing, OnAutocastSwingFired_Authority, RollSwingDamage, GrantStyleXp, GetDamageInstigator, OnAfterSnapshotFail).
- UAutoAttackAbility (player driver) — existing class, refactored to subclass the base. Owns spellbook autocast, style XP, "Equip a weapon" UX, PlayerState targeting + activity descriptor, ammo / rune consumption from bag.
- UEnemyAutoAttackAbility (enemy driver) at Source/CRADL/Enemy/EnemyAutoAttackAbility.{h,cpp} — pawn-side Equipment / targeting, attacker level via ICombatStatsProvider on the avatar, no autocast / XP / UI.
New combat interfaces under Source/CRADL/Combat/:
- IAttackerCappedTarget at AttackerCappedTargetInterface.h — the multi-attacker cap surface (TryRegisterAttacker / UnregisterAttacker / WouldAcceptAttacker). Implemented by ATargetDummy and AEnemyCharacter.
- ICombatStatsProvider at CombatStatsProviderInterface.h — combat-stats query (GetCombatSkillLevel(SkillTag) / GetDefenseBonusForType(DamageType)). Implemented by ATargetDummy, ACradlPlayerState, AEnemyCharacter.
New combat helper:
- CradlCombat::ResolveCombatContext(Info) at Source/CRADL/Combat/CradlCombatRange.{h,cpp} — returns FCombatActorContext { Equipment, Skills, AttributeSet, Spellbook } with PS-first / avatar-fallback resolution. Single canonical site for "owner is PS or pawn" disambiguation.
New abilities under Source/CRADL/Enemy/:
- UEnemyDeathAbility (NetExecutionPolicy = ServerOnly, triggers on Combat.Event.Death).
New placeable actor: AEnemySpawner at Source/CRADL/Enemy/EnemySpawner.{h,cpp}.
Helper namespace: CradlLoot at Source/CRADL/Loot/CradlLoot.{h,cpp} — single home for RollDropTable. Sibling to CradlCombat in the combat module.
Combat-level scalar: free function CradlCombat::ComputeCombatLevel(const ACradlPlayerState*) at the existing combat namespace. Formula locked in (see Combat Level Scalar); future numeric tuning replaces constants, not the function shape.
Validators (per CLAUDE.md "validators in lockstep"):
- UCradlEnemyDefinitionValidator
- UCradlDropTableDefinitionValidator
Both under Source/CRADLEditor/Validators/, inheriting UEditorValidatorBase, hitting both CanValidateAsset_Implementation and ValidateLoadedAsset_Implementation.
Settings additions on UCradlCombatSettings:
- DropOwnershipSeconds (float, default 60).
- EnemyCorpseSeconds (float, default 3).
Open Questions
Explicitly deferred so they don't get re-litigated as "missing." Items here are known unknowns — the contract is silent on them by design.
- Reproducibility of stat rolls. Two players in the same session see the same enemy spawn with the same rolled stats (server is authoritative); a save-and-reload picks fresh rolls. Deterministic seed reproducibility (for repro replays, deterministic testing) would be additive and isn't blocking v1.
- Elite / boss variants.
Enemy.Rank.*namespace is reserved. Whether elites are a per-spawn modifier (rare elite-tier stat-tuning entry rolls on spawn), a separate spawner flag, or a definition flavor (UEnemyDefinitionflagged as boss) is a content-design call. - Instanced encounters. Dungeons / per-player instances. Out of scope for the open-world spawner model; would land as a sibling system.
- PvE-summon / pet pawns. If the player ever gets a follower (familiar, pet), the brain/body split here is intended to extend cleanly — same pawn shape, allegiance-flipped BT, different drop-table semantics (typically none). Not v1.
- Per-attacker eligibility for skill-gated drops. The drop-table doc states skill requirements query the tagger's skills. For multi-attacker kills where the tagger doesn't meet a requirement but another contributor does, is the drop rolled against the eligible contributor instead? v1 keeps it simple (tagger only); revisit if multi-attacker content surfaces real friction.
- Ground item ownership window tuning. Default 60s; whether this varies by item rarity, location (PvP vs PvE zone), or drop-table tier is a downstream design pass.
- Bestiary / kill log. UI surface that consumes
UEnemyRegistry. Not part of the contract; lands when the UI is designed. - Boss-specific multi-attacker cap overrides. The combat contract already supports
MaxAttackersper pawn. Whether bosses need finer-grained mechanics (named-player slot reservations, threat-based priority) is a content question.