* 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
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:
DamageMathfor every branch above;FThreatTablefor forced target expiry, decay and removal;UClassKitDefinition::IsDataValidfor two signatures and five slots. - Functional:
FT_Combat_FunnelappliesGE_Damagefrom a player to a player and asserts health is unchanged and the cue fired;FT_Combat_Downdrives a player to zero, assertsState.Downed, revives through the interaction path and asserts health;FT_Combat_Swingswings 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.