Files
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

Rewrites the design and engineering docs of the two earlier Unity projects
(Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that
builds and proves three things in order: a movement controller, fighting and
crafting. Neither earlier core loop is carried; their ideas are catalogued as
features with what each needs.

- Docs/Design.md: the human summary (pillars, fixed decisions, glossary)
- Docs/Steps.md: a ladder of fifteen proofs, the first six in full
- Docs/Ideas.md: salvaged ideas from both projects, none scheduled
- Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals
- Docs/Spec/: notation, Architecture, Movement, Interaction, Combat,
  Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons
- CLAUDE.md and README.md for the repository


* Add the stat block: one attribute set on every body, every influence an effect

Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and
every outside influence on it is a gameplay effect: base values, buffs,
debuffs, ground surfaces, carried weight, gear, being downed. Counters are
tags and attributes on the receiver: immunity blocks by tag, resistance
scales by attribute, cleanse removes by tag. Same status from several
sources: strongest wins; different statuses multiply.

- Docs/Spec/Stats.md: the block, effects and their five sources (abilities,
  areas, surfaces, items, the world), stacking, counters, readers, tests
- Movement: the movement component's tagged speed-multiplier map is removed;
  speed is tuning times the MoveSpeed attribute; the ground surface trace
  turns mud into an effect; the gym gets mud and ice patches
- Combat: the attribute set moves to Stats; enemies and kits carry
  FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags;
  taunt and mark are statuses
- Interaction: carried weight is GE_Encumbered against CarryCapacity
- Crafting: class bonuses are the WorkSpeed and WorkQuality attributes;
  enchantments may grant a holder effect
- Steps: step 5 builds the block with mud, a volume and an immunity
- Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
2026-09-15 17:54:00 +03:00

28 KiB

Combat

Owns attributes, the one damage funnel, health and the downed state, melee hit detection and feel, enemies, the ability system, class kits, and the rules that keep gear and mods from dissolving the classes. It does not own how a body moves (Movement.md, whose root-motion hooks the movement abilities use), how a player touches a downed teammate (Interaction.md), or where a weapon's numbers come from when the weapon was crafted (Crafting.md, which produces the FWeaponProfile defined here).

Read Architecture.md first. Steps 7 to 11 in ../Steps.md.

Decisions

[DECIDED] The Gameplay Ability System. Attributes, abilities, effects, cooldowns, tags, cues.

  The earlier project hand-built a damage resolver, a health service, a cooldown tracker, an ability runner and a
  client-to-host relay, then spent a step making them replicate. GAS is those five things, server-authoritative
  with client prediction, already replicated, and the engine's own combat samples are written on it. The cost is
  its learning curve, which is paid once, and its opinions, which happen to be this project's: one attribute set,
  effects as data, abilities as classes, everything addressed by tag.
[DECIDED] One damage funnel. Every point of damage in the game passes through one execution calculation.

  Melee, projectiles, abilities, falling, hazards, a shove into a pit. One place, for three reasons: modifiers from
  gear and upgrades apply in one place, telemetry is emitted in one place, and the no-friendly-fire rule is enforced
  in one place rather than remembered at twenty call sites. No ability, weapon or hazard writes Health directly.
[DECIDED] Down before death. Zero health is downed: immobile, revivable, bleeding out. Death is what happens when
nobody comes. Any player can revive; some kits do it faster. When no player is standing, everyone downed dies at
once, because nobody can revive anybody.
[DECIDED] The ability system component is on the player state for players and on the pawn for enemies.
A player's cooldowns, attributes and class survive their body; an enemy's die with it.
[SALVAGED] Kits define ability access. Gear defines stat and utility access, and crosses class lines. Mods bend
numbers and change how an ability you have behaves, and never grant another class's signature ability. Without that
line, free-form gear dissolves five classes into five slightly different damage dealers within one content patch.
Gear and mods themselves are not built in these steps; the flag that guards the line is, because it is free now.

Layout

Source/<Project>Core/Combat/
├── WeaponProfile.h             // FWeaponProfile: the one shape both authored and crafted weapons produce
├── DamageMath.h / .cpp         // pure: ComputeDamage(context) -> FDamageResult
└── ThreatTable.h / .cpp        // pure: who an enemy wants to hit

Source/<Project>/Combat/          // the stat block and the ability system component live in Stats/, see Stats.md
├── DamageExecution.h           // UGameplayEffectExecutionCalculation: THE funnel
├── CombatantComponent.h        // per body: team, downed/dead state machine, revive target, hit reaction
├── WeaponDefinition.h          // UPrimaryDataAsset for authored weapons
├── WeaponActor.h               // the held weapon: mesh on the hand socket, its profile, its trace sockets
├── Abilities/
│   ├── KitAbility.h            // UGameplayAbility base: InputTag, bSignature, activation policy
│   ├── GA_MeleeAttack.h        // montage, hit window, server sweep, apply damage
│   ├── GA_Dodge.h  GA_Blink.h  GA_ShoulderCharge.h  GA_Taunt.h  GA_Mark.h  GA_Heal.h  GA_Downed.h  GA_Revive.h
│   └── GA_Fireball.h  GA_Volley.h                     // step 11, projectiles
├── ClassKitDefinition.h        // UPrimaryDataAsset: abilities in slot order, starting weapon, base attributes
├── Enemies/
│   ├── EnemyDefinition.h       // UPrimaryDataAsset: stats, montages, drops later
│   ├── EnemyCharacter.h        // ABaseCharacter with its own ASC
│   ├── EnemyController.h       // AAIController + StateTree + perception + the threat table
│   └── EncounterSubsystem.h    // UWorldSubsystem, server: spawns and counts, party-size scaling
├── ProjectileActor.h           // step 11
└── Cues/                       // GameplayCue notifies: impact, hit stop, camera kick, outline flash

Content/Combat/
├── Definitions/DA_Weapon_Sword, DA_Weapon_Mace, DA_Weapon_Dagger, DA_Weapon_Staff, DA_Weapon_Bow
├── Definitions/DA_Kit_Warrior, DA_Kit_Ranger, DA_Kit_Mage, DA_Kit_Cleric, DA_Kit_Rogue
├── Definitions/DA_Enemy_Goblin
├── Effects/GE_Damage, GE_Heal, GE_Cooldown_*, GE_BleedOut, GE_Invulnerable, GE_FallDamage
├── Abilities/GA_*              // Blueprint children setting montages and cues only
├── Animations/AM_Melee_*, AM_Hit_*  // montages with the hit-window notify states
└── Cues/GC_*

Types at a glance

Type Module Lifetime Notes
FWeaponProfile Core value Authored or crafted, same struct
DamageMath::ComputeDamage Core pure The maths the execution calls
FThreatTable Core value Owned by an enemy controller
UStatBlockAttributeSet Gameplay (Stats) per ASC The stat block, see Stats.md
UDamageExecution Gameplay asset-referenced Referenced by GE_Damage
UCombatantComponent Gameplay per body Team, downed state, revive target, hit reaction
UWeaponDefinition, UClassKitDefinition, UEnemyDefinition Gameplay asset Content
AWeaponActor Gameplay per equipped weapon Attached to the hand socket
UKitAbility and children Gameplay granted per ASC Abilities
AEnemyCharacter, AEnemyController Gameplay per enemy Server-driven
UEncounterSubsystem Gameplay world, server Spawning and the wipe

Attributes

The numbers combat reads and writes are on the one stat block every body carries, UStatBlockAttributeSet, specified in Stats.md: Health and MaxHealth, AttackPower and AttackSpeed, Armour, MagicResist and KnockbackResist, and the two meta attributes the funnel writes. Combat owns what they mean; it does not own the set, and it adds nothing to it without a row in that document's table.

// UStatBlockAttributeSet, the parts combat uses
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingDamage) // META: written by the execution, consumed below, never replicated
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingHeal)   // META, same

virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
//   IncomingDamage: Health = clamp(Health - Incoming, 0, Max); if it reached zero and was not zero, raise
//   OnOutOfHealth(instigator, causer, context). Emit player_damaged / enemy_damaged. Clear the meta attribute.
//   IncomingHeal:   overheal past MaxHealth is returned to the caller as a value, because a heal that overflows
//   is a launch (the salvaged joke, see Abilities). Health clamps; the surplus goes out through OnOverheal.

Base values are a FStatBlockDefaults on the kit or the enemy definition, applied through the one init effect on grant or spawn; nothing sets an attribute directly. A body's speed does not live in combat at all: it is MoveSpeed on the block, and a slow from a frost bolt changes it the same way mud does.

The damage funnel

// Source/<Project>Core/Combat/DamageMath.h   // pure
struct FDamageContext
{
    float BaseAmount;                  // from the weapon profile or the ability's SetByCaller Data.Damage
    FGameplayTag DamageType;           // Damage.Type.Physical | Magic | True
    FGameplayTag Source;               // Damage.Source.Melee | Projectile | Ability | Fall | Hazard | Shove
    float SourceAttackPower = 1.f;     // captured attribute
    float TargetArmour = 0.f;          // captured attribute, physical
    float TargetMagicResist = 0.f;     // captured attribute, magic
    bool bSourceIsPlayer = false, bTargetIsPlayer = false;
    bool bTargetMarked = false;        // Status.Debuff.Marked: everyone hits harder
    bool bTargetInvulnerable = false;  // dodge i-frames, a shield
    FGameplayTagContainer TargetImmunities; // Immune.Damage.<Type> tags on the target: matching damage is zero
    FGameplayTagContainer GrantedTags; // from an enchanted weapon: Item.Enchant.Fire.Penetration ...
};
struct FDamageResult { float FinalAmount = 0.f; bool bDiscardedFriendlyFire = false; bool bCritical = false; };

namespace DamageMath
{
    // pure. Tested for: the friendly-fire gate; True damage ignores armour and resist; a mark multiplies;
    // invulnerable is zero; an Immune.Damage.<Type> tag zeroes that type; fall damage is never friendly fire
    // even when a player caused the fall.
    FDamageResult ComputeDamage(const FDamageContext& Ctx, const struct FDamageTuning& Tuning);
}
/**
 * THE funnel. Referenced by GE_Damage and by nothing else; every damaging effect in the project is GE_Damage with
 * SetByCaller magnitudes, or a subclass of it. Runs on the server only, as executions do.
 */
UCLASS()
class UDamageExecution : public UGameplayEffectExecutionCalculation
{
    GENERATED_BODY()
public:
    UDamageExecution();   // captures AttackPower from the source; Armour, MagicResist and the State, Status and Immune tags from the target
    virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& Params,
                                        FGameplayEffectCustomExecutionOutput& Out) const override;
    // 1. Build FDamageContext from the spec: Data.Damage, the type and source tags on the spec, the captured
    //    attributes, team membership of instigator and target (IGenericTeamAgentInterface), target state tags.
    // 2. THE FRIENDLY FIRE GATE. Both players: FinalAmount is zero. The effect still applies, so the impulse
    //    and the cue survive. Player-on-player is impulse and ragdoll, never health loss. This is the
    //    consequence-free griefing rule from the earlier project, enforced here so no ability can violate it.
    // 3. DamageMath::ComputeDamage. Modifiers from gear read the source's granted tags and attributes; nothing
    //    is pushed into this class by another system, it reads.
    // 4. AddOutputModifier(IncomingDamage, Additive, FinalAmount).
    // 5. The attribute set's PostGameplayEffectExecute emits telemetry and raises OnOutOfHealth. This class
    //    emits nothing itself: one emitting site per event.
};

Fall damage is a GE_FallDamage applied by the character's Landed through the same funnel with Damage.Source.Fall and no instigator; hazards are the same with Hazard. A shove is GE_Damage with zero base amount and an Impulse magnitude: the gate discards nothing because there is nothing, and the impulse cue lands.

Health, down, revive, wipe

/** Per body. Turns attribute events into the downed/dead state and exposes the revive prop. */
UCLASS()
class UCombatantComponent : public UActorComponent, public IInteractable
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly) FGenericTeamId Team;      // players 1, enemies 2; the attitude solver says 1 vs 2 is hostile
    FGuid CombatantId;                                    // stable runtime identity for the threat table and telemetry

    // Server, on OnOutOfHealth:
    //   Enemies: grant State.Dead, play the death montage through a cue, disable collision, the encounter counts it.
    //   Players: ASC->TryActivateAbilityByClass(GA_Downed). GA_Downed grants State.Downed, applies GE_BleedOut
    //   (30 s duration, on expiry -> Die), applies GE_Downed (MoveSpeed overridden to zero), forces a drop through the carry
    //   component, drops the camera, and enables this component's revive collider on the Interactable channel.
    //   Then: if no player in the world is standing, every downed player dies now (a solo down is an instant wipe;
    //   bleed-out only matters with a party) and the encounter subsystem's wipe fires.
    // IInteractable, the revive prop:
    //   GetPrompt: Interact.Verb.Revive, enabled when the target has State.Downed and the asker does not.
    //   Interact (server): activate GA_Revive on the reviver, which holds for ReviveSeconds (faster for a Cleric),
    //   then removes GE_BleedOut and restores a fraction of MaxHealth. Emit player_revived with down_duration_s.
    // Hit reaction: OnDamaged raises HitReaction_Cosmetic(direction); the animation blueprint bends the spine with
    //   a damped spring in the direction of the hit, on top of whatever plays, and never moves the hands.
};

A downed player is the first prop in the game, and reviving gets the server-side range and permission re-check for free from Interaction.md. It is the reason interaction is built before combat.

Weapons and the melee swing

// Source/<Project>Core/Combat/WeaponProfile.h
/**
 * The one shape a weapon has at the moment of the swing. An authored UWeaponDefinition produces it directly; a
 * crafted item produces it through CraftingRules::MakeWeaponProfile. The melee ability, the action bar and the
 * damage funnel read this and nothing else, which is the seam that lets a crafted sword be a sword.
 */
struct FWeaponProfile
{
    FGameplayTag WeaponTag;            // Item.Weapon.Sword; a crafted item carries its family tag here
    float Damage = 12.f;
    float ReachCm = 180.f;
    float ArcHalfWidthCm = 60.f, ArcHalfHeightCm = 50.f;
    float WindupSeconds = 0.12f;       // press to hit check: the tell
    float SwingSeconds = 0.55f;        // press to next press
    float HitStopSeconds = 0.06f;      // cosmetic, client side
    FGameplayTag DamageType;           // Damage.Type.Physical by default
    FGameplayTagContainer GrantedTags; // an enchanted blade grants Item.Enchant.* here
    bool bTwoHanded = false;           // a greatsword occupies both hands, see Interaction.md
    FSoftObjectPath Montage;           // AM_Melee_*; a crafted weapon uses its family's montage
};
UCLASS()
class UGA_MeleeAttack : public UKitAbility
{
    GENERATED_BODY()
    // Net execution policy: LocalPredicted. The montage plays immediately on the owner; the server plays it too and
    // does the hit check itself. Activation: on Input.Attack, blocked by State.Downed, State.Carrying.TwoHanded,
    // State.Dodging, and by its own cooldown (SwingSeconds, a GE_Cooldown_Melee with SetByCaller duration).
    //
    // ActivateAbility:
    //   Profile = Owner->GetWeaponActor()->GetProfile(); timings divided by the body's AttackSpeed attribute
    //   PlayMontageAndWait(Profile.Montage, rate = AttackSpeed)   // predicted
    //   WaitGameplayEvent(Event.Montage.Hit)                      // fired by ANS_HitWindow on the montage, at WindupSeconds
    //   on event, SERVER ONLY (HasAuthority):
    //     sweep a box (ArcHalfWidth x ArcHalfHeight x Reach) from the eye along the body's aim on the Enemy channel
    //     for each hit, once per swing: Linecast eye -> hit point on the world channel; a wall blocks it
    //     for each surviving hit: MakeOutgoingSpec(GE_Damage), SetByCaller(Data.Damage, Profile.Damage),
    //       add DamageType and Damage.Source.Melee to the spec, ApplyToTarget
    //     execute cue GameplayCue.Melee.Impact at the hit point on every peer
    //   EndAbility on montage end.
    //
    // Why a single box after the wind-up rather than a per-frame sweep along the blade: it is what the earlier
    // project shipped, it hits the whole pack, and it reads. A blade-socket sweep is a change to this one ability
    // if the feel pass wants it; nothing else would move.
};

Feel, all cosmetic and all client-side through gameplay cues: hit stop (a brief CustomTimeDilation dip on the local player and the struck enemy, never on the server), a camera kick through a camera shake on the owner's controller, and the procedural hit reaction on the struck body. Every duration passes through the motion accessibility scale, so reduced motion turns them down.

The wind-up is what makes the animation and the rules agree: the player's hit check is delayed past the press by WindupSeconds, and the goblin's damage lands after its own wind-up, missing if the target has stepped out of reach. The tell is real; reading it and stepping back works.

Combat feel is the honest unknown of these steps. No document settles whether a swing lands well. Step 7 is not done until one training dummy and then one goblin are satisfying to hit, and the numbers that made it so are committed.

Enemies

UCLASS(BlueprintType)
class UEnemyDefinition : public UPrimaryDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly) FGameplayTag EnemyTag;           // Enemy.Goblin
    UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats;    // 30 health, MoveSpeed 1: the same block a player has (Stats.md)
    UPROPERTY(EditDefaultsOnly) TObjectPtr<UMovementTuning> Tuning;   // the goblin's 320 cm/s walk; the block multiplies it like anyone's
    UPROPERTY(EditDefaultsOnly) FWeaponProfile Attack;           // 8 damage, 1.2 s, 0.35 s wind-up: the goblin's claws
    UPROPERTY(EditDefaultsOnly) float AggroRangeCm = 1000.f;
    UPROPERTY(EditDefaultsOnly) float LeashRangeCm = 2500.f;
    UPROPERTY(EditDefaultsOnly) float FleeSecondsOnAllyDeath = 2.5f;   // reads as cowardice; funnier than fighting to the death
    UPROPERTY(EditDefaultsOnly) int32 ThreatCost = 1;           // what it costs an encounter budget
    UPROPERTY(EditDefaultsOnly) bool bRequiresRole = false;     // ineligible below three players, see scaling
    UPROPERTY(EditDefaultsOnly) TSoftClassPtr<AEnemyCharacter> Body;
    UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStateTree> Brain;
};
// Source/<Project>Core/Combat/ThreatTable.h   // pure
struct FThreatTable
{
    void AddThreat(FGuid Combatant, float Amount);        // damage dealt to me, healing done near me
    void ForceTarget(FGuid Combatant, float Seconds);     // a taunt
    void Decay(float DeltaSeconds);
    TOptional<FGuid> GetTarget() const;                   // forced target while it lasts, else highest threat
    void Remove(FGuid Combatant);
};

The controller is an AAIController running a StateTree from the definition, with a perception component for sight and the threat table for choice. States: Idle, Chase, Attack (wind-up, hit through the same melee ability class the player uses, on the enemy's own ASC), Flee (on an ally's death, for FleeSecondsOnAllyDeath), Leash (back to the spawn point past LeashRangeCm). Threat accrues from damage dealt and from healing done, which is why a healer needs a tank. The threat table is what makes a taunt mean something.

Party-size scaling is a reserved rule, not a built one. The encounter subsystem reads the party size from the game state and scales the enemy budget sublinearly: 1 player 1.0, 2 players 1.75, 3 players 2.4, 4 players 3.0. Linear scaling is the obvious choice and it is wrong: four coordinated players are worth far more than four times one. Enemies with bRequiresRole are ineligible below three players, which is what "playable alone" means concretely: a constraint on which enemies may appear, not a damage multiplier. Only the goblin exists, so the subsystem multiplies a count and filters a list; the table is written down so the first second enemy inherits it.

Abilities and kits

UCLASS(Abstract)
class UKitAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, Category = "Kit") FGameplayTag InputTag;   // Input.Ability.1..4, or Input.Attack, Input.Dodge
    UPROPERTY(EditDefaultsOnly, Category = "Kit") bool bSignature = false; // THE flag a mod may never cross
    UPROPERTY(EditDefaultsOnly, Category = "Kit") FText DisplayName;       // the action bar's label
    // Cooldown and cost are the engine's: a GE_Cooldown_<Ability> with a Cooldown.<Ability> granted tag,
    // and an optional stamina cost effect. CommitAbility applies both. The HUD reads the cooldown tag's remaining time.
};

USTRUCT()
struct FKitAbilitySlot
{
    GENERATED_BODY()
    UPROPERTY(EditDefaultsOnly) TSubclassOf<UKitAbility> Ability;
    UPROPERTY(EditDefaultsOnly) FGameplayTag InputTag;   // which slot; the ability's own InputTag is the default
};

UCLASS(BlueprintType)
class UClassKitDefinition : public UPrimaryDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag;                          // Class.Warrior
    UPROPERTY(EditDefaultsOnly) FText DisplayName;
    UPROPERTY(EditDefaultsOnly) TArray<FKitAbilitySlot> Abilities;             // slot order; at most four on the bar
    UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats;                   // applied through the one init effect (Stats.md)
    UPROPERTY(EditDefaultsOnly) TObjectPtr<UWeaponDefinition> StartingWeapon;
    UPROPERTY(EditDefaultsOnly) float ReviveSeconds = 4.f;                     // the Cleric's is 2
    // IsDataValid: at most one bSignature ability, at most four slots, distinct input tags.
};

UExtendedAbilitySystemComponent routes input by tag: on Input.Ability.2 pressed it activates every granted ability whose asset tags contain that input tag. The player character forwards its IA_Ability* and IA_Attack presses there and decides nothing. Granting a kit is GrantKit(const UClassKitDefinition&) on the player state's component: ApplyDefaults(BaseStats), give each ability with its input tag, equip the starting weapon. Changing class regrants.

The kits, sketched. Each is a role, a signature no mod may grant elsewhere, and a silly escalation that later content pushes on. Two abilities each for the steps; slots three and four are content.

Kit Role Weapon Signature Second ability Escalation later
Warrior Hold attention, absorb, open a path Sword, or shield and mace Taunt: force nearby enemies onto you Shoulder charge: damage to enemies in a cone, impulse to teammates Charge launches teammates across rooms
Ranger Sustained ranged damage, mobility Bow (step 11); dagger until then Mark: a marked target takes more from everyone Volley Arrows that carry things
Mage Burst and area, crowd control Staff Blink: short teleport along the aim Fireball (step 11) Blink takes whoever is touching you
Cleric Sustain, revives, buffs Mace Mass heal Mend: targeted heal; overheal launches the target The heal launches people over walls
Rogue Burst, mobility Dagger Backstab: aimed, high damage, short cooldown Shadowstep: a self blink along the aim

Effects an ability may apply, each through the engine's own mechanism and never by writing a value: Damage (GE_Damage through the funnel), Heal (GE_Heal into IncomingHeal), Impulse (LaunchCharacter on the target through a client RPC to its owner, since movement is owner-predicted, scaled by the target's KnockbackResist), Taunt (Status.Debuff.Taunted through ApplyStatus, which the enemy controller turns into ForceTarget; a boss's Immune.Status.Taunted blocks it like any status), Mark (Status.Debuff.Marked for a duration, read by the funnel), Slow, Haste and every other buff or debuff (ApplyStatus, see Stats.md), Blink and Dodge (root-motion sources through the movement component, with State.Invulnerable for the dodge's i-frames).

Parity target: every kit completes the same gym fight solo in roughly the same time. Not equal damage, equal viability. enemy_killed.killer_class against time_to_kill_s is the measurement.

The recoverability contract, salvaged whole: any verb that lets one player inconvenience another (a charge into a carrier, a heal off a ledge, a blink with a passenger) must have a recovery available to the victim that takes less time than the verb took to perform, and none may down, kill or permanently deprive. Damage is gated in the funnel; things are never destroyed. Every such verb emits grief_action, because a rule nobody can measure is a rule nobody can defend.

Projectiles (step 11)

AProjectileActor with a UProjectileMovementComponent, spawned on the server by GA_Volley and GA_Fireball and replicated; the firing client spawns a local cosmetic copy on activation so the shot leaves the hand without a round trip (predict the animation, not the ownership), and the cosmetic copy hides itself when the replicated one arrives. On hit the server applies GE_Damage with Damage.Source.Projectile. Travel time and drop are real, so aim matters. The bow's FWeaponProfile has reach zero and a montage that fires Event.Montage.Release instead of Hit.

Networking

State Authority Mechanism
Attributes Server Replicated attribute set on the ASC (Mixed mode for players, Minimal for enemies)
Ability activation Client requests, server decides GAS prediction keys; LocalPredicted for the player's abilities, ServerOnly for GA_Downed
Damage Server Executions run on the server only
Hit detection Server, using the owner's replicated aim A little client trust in the aim, which is fine in cooperative play
Cooldowns Server, client predicts Cooldown effects with prediction; the bar shows the prediction and takes the correction
Enemy brains Server StateTree ticks on the server; bodies replicate movement and montages
Impulses on players Server decides, owner applies Client RPC to the owner's body, because movement is owner-predicted
Statuses on bodies Server, own-ability ones predicted ApplyStatus, see Stats.md
Cues Everywhere Gameplay cues, unreliable multicast; presentation only

Telemetry

Event When Payload
enemy_spawned The encounter spawns enemy, spawn_point, party_size
enemy_killed Health reaches zero enemy, killer_class, weapon, ability, time_to_kill_s
ability_used Activation commits ability, class, target_count
player_damaged Damage lands on a player source, attacker, amount, health_after
player_downed Health reaches zero source, party_alive, party_size
player_revived Revive completes by_class, down_duration_s
player_died Bleed-out or wipe down_duration_s, cause
party_wiped Nobody standing enemies_alive, duration_s
grief_action A recoverable verb on a teammate verb, actor, target

Tests

  • Automation: DamageMath for every branch above; FThreatTable for forced target expiry, decay and removal; UClassKitDefinition::IsDataValid for two signatures and five slots.
  • Functional: FT_Combat_Funnel applies GE_Damage from a player to a player and asserts health is unchanged and the cue fired; FT_Combat_Down drives a player to zero, asserts State.Downed, revives through the interaction path and asserts health; FT_Combat_Swing swings at a dummy behind a wall and asserts no damage.

Open questions

  • Q1. Does a shove into a pit count as friendly fire? The gate discards player-to-player damage, but a shove that causes a fall produces fall damage from the world. Recoverable if the victim is downed rather than killed and can be revived. Decide when the first pit exists; the funnel already distinguishes the cases.
  • Q2. Resource model. Cooldowns only for these steps. Stamina is an attribute in the set so that adding a cost is a cost effect, not a refactor.
  • Q3. Hit registration latency. Server-authoritative with the owner's aim is the plan. At what latency does a swing feel wrong? Test at 100 ms and 150 ms in step 7 and write the number down.
  • Q4. A blade sweep instead of a box. If the box reads as imprecise once real animations arrive, sweep the weapon actor's tip and base sockets per frame during the hit window. Contained to GA_MeleeAttack.
  • Q5. Which kits exist at all. Five are named because two source projects between them named these five and their signatures survive contact with the guardrail. Steps 9 and 10 build two; the rest are content.
  • Q6. Cross-class weapon animation. A Warrior carrying a bow uses the bow's montage, which is authored per weapon family, not per class. That is the cheap answer and it is the plan; revisit when animation gets real.