Unreal Engine 5.8 · one runtime C++ module · Win64
ThreatTable answers one question for an enemy: who should I be attacking?
It does not move anybody, does not swing anything and does not decide when to attack. It is the layer
between "who hit me" and "who I fight", and it is the layer nearly every project writes badly the first
time — because the obvious rule, attack whoever has done the most damage, produces an enemy that spins
on the spot between two players who hit for the same amount.
| Engine version | Unreal Engine 5.8 |
| Supported target platforms | Win64 — the module's PlatformAllowList |
| Modules | One: ThreatTable, Type: Runtime, LoadingPhase: PreDefault |
| Source | Full C++ source included |
| Build configurations verified | Editor Development · Game Development · Game Shipping |
| Engine module dependencies | Core, CoreUObject, Engine, GameplayTags, DeveloperSettings · Private: RenderCore |
| Third-party code | None |
| Other plugin dependencies | None. In particular not AIModule — see §6 |
| Project type | C++ and Blueprint-only projects |
| Network replication | No. Threat is server-side AI state; see §7 |
| Automation tests | Included (ThreatTable.*) |
ThreatTable/ into your project's Plugins/ folder and restart the editor.That is the whole integration. There is no controller to replace, no behaviour tree to adopt, and no
damage system to switch to.
Damage arrives in a project in a dozen places — AnyDamage, a Gameplay Ability execution, an overlap, a
hitscan trace — and none of them is holding the enemy's threat component. Add Threat To takes the actor,
finds the component itself, and does nothing at all if there is none. A project can therefore route
everything it damages through one node, and only the actors that keep a table react. Barrels do not need
an exception.
A BT service with two nodes: Get Top Target → Set Blackboard Value as Object. Nothing else is
needed, and nothing about your tree has to change.
Every entry loses DecayPerSecond of its threat each second. Without it, the first player to land a hit
holds the enemy for the rest of the fight; with it, a player who steps back falls behind one who keeps
fighting, in a few seconds, without anybody scripting a "drop aggro" action.
The decay is exponential, per second — not a subtraction, and not per evaluation. A fixed subtraction
behaves differently in a game that deals 10 damage and one that deals 10,000; per-evaluation decay makes
threat depend on the frame rate. ThreatTable.Decay.IsPerSecondNotPerCall pins this down: two half-second
steps must equal one whole-second step.
An entry is dropped when nothing has arrived for it in ForgetAfterSeconds (default 15), or when the actor
is further away than ForgetBeyondDistance (default 6000, the leash). Forgetting is not decay: an entry at
zero threat is still a candidate until it is forgotten, which is what lets an enemy remember you through a
stun.
A challenger has to be SwitchMargin times ahead (default 1.1 = ten percent) before the target actually
changes. This is the whole point of the plugin. Two players hitting for the same amount trade the lead by a
point at a time; a plain "highest threat wins" rule turns the enemy around every evaluation and it never
swings at anybody.
A burst of damage clears any margin inside a single frame. MinSecondsBetweenSwitches (default 0.5) is the
second half of the same guard. Together they are what makes a fight readable.
Both live in one pure function, Should Switch Target, which is public — so you can ask ThreatTable what it
would do, and so the automation tests can pin it down without a world.
Taunt(Instigator, Seconds) forces the target for a while.
A taunt adds no threat, and that is deliberate. Adding "enough" threat is impossible to tune: too little
against a strong attacker, and the taunter is stuck on top for the rest of the fight afterwards. A taunt
wins while it lasts and stops winning the moment it ends — which is what a taunt is.
Three consequences worth knowing:
| Source | What it is for | How it is treated |
|---|---|---|
Damage |
The ordinary case | Taken as given |
Healing |
A healer who never touched this enemy still has to be attackable | Multiplied by HealingThreatMultiplier (0.5) |
Taunt |
A deliberate pull | Wins while it lasts, adds nothing |
Proximity |
So an enemy with an empty table attacks somebody | Trickles in, capped at ProximityThreatCap |
Script |
The project said so | Never scaled |
Proximity is off per component (bProximityThreat) and capped on purpose: standing near must never
out-aggro fighting. It only accrues for actors already in the table — walking the whole world looking
for candidates would turn a cheap component into an O(actors) one every evaluation.
Broadcast Threat (Instigator, Origin, Radius, Amount) adds threat for one actor in every table within
the radius. This is what makes a room turn around when one guard is attacked.
Note that it adds threat rather than forcing a target: guards who are already busy with somebody keep their
own fight, which is what a group should do.
Forget Everywhere (Instigator) removes an actor from every table in the world. Call it once when a player
dies, and no enemy keeps walking towards the corpse.
AIModule is deliberately not a dependency. The topSenseBudget if perception is what is costing you frames.Get Entries returns the sorted table so youThreat is not replicated, and should not be. It is server-side AI state: the server decides who the
enemy attacks and replicates the result (the enemy's rotation, its animation, its damage events) through
the machinery you already have. Replicating the table itself would send a table of floats per enemy per
tick to every client, for data no client can act on.
If you want a threat meter on the client, replicate the one number it needs — usually "am I the top target"
as a boolean on the player state.
| Command | Effect |
|---|---|
ThreatTable.Debug 1 |
Draw every table above its owner, with a red line to the current target |
ThreatTable.Dump |
Write every table's target, entry count, top/runner-up threat and switch counters to the log |
Get Stats returns the same numbers per component. Two of them are worth watching:
SwitchesSuppressed — how often the margin or the interval refused a change. Zero over a whole fightTotalForgotten — entries dropped. A number climbing while nobody leaves the fight usually meansForgetBeyondDistance is smaller than the arena.UThreatTableComponent| Function | Signature |
|---|---|
AddThreat |
float (AActor* Instigator, float Amount, EThreatSource Source = Damage) |
SetThreat |
void (AActor*, float) |
Taunt |
void (AActor*, float Seconds = 5) |
ClearTaunt |
void () |
Forget |
void (AActor*) |
ClearTable |
void () |
GetTopTarget |
AActor* () |
GetThreat |
float (AActor*) |
GetEntries |
TArray<FThreatEntry> () — sorted, highest first |
GetStats |
FThreatTableStats () |
IsTaunted |
bool () |
Events: OnTargetChanged (Table, NewTarget, OldTarget), OnThreatAdded (Table, Instigator, Amount).
Per-component overrides: bOverrideProjectSettings, DecayPerSecond, ForgetAfterSeconds,
ForgetBeyondDistance, SwitchMargin, bProximityThreat, bDrawDebug.
UThreatTableStatics| Function | Signature |
|---|---|
AddThreatTo |
float (AActor* Target, AActor* Instigator, float Amount, EThreatSource) |
TauntTarget |
void (AActor* Target, AActor* Instigator, float Seconds) |
GetTopTargetOf |
AActor* (AActor* Target) |
GetThreatTable |
UThreatTableComponent* (AActor* Target) |
BroadcastThreat (world) |
int32 (AActor*, FVector, float Radius, float Amount) |
ForgetEverywhere (world) |
int32 (AActor*) |
ApplyDecay |
float (float Threat, float DecayPerSecond, float DeltaSeconds) |
ShouldSwitchTarget |
bool (float Challenger, float Current, float Margin, bool bTaunted, float SinceLastSwitch, float MinGap) |
UThreatTableSettingsProject Settings ▸ Plugins ▸ ThreatTable.
| Property | Default |
|---|---|
DecayPerSecond |
0.05 |
ForgetAfterSeconds |
15 |
ForgetBeyondDistance |
6000 |
SwitchMargin |
1.1 |
MinSecondsBetweenSwitches |
0.5 |
HealingThreatMultiplier |
0.5 |
ProximityThreatPerSecond / Cap / Radius |
1 / 25 / 800 |
MaxEntries |
32 |
EvaluationInterval |
0.2 |
| Symptom | Cause and cure |
|---|---|
| The enemy never picks a target | Nothing is adding threat. Check that your damage path calls Add Threat To, and that the actor being hit is the one carrying the component. |
| It picks a target and never changes | SwitchMargin too high, or MinSecondsBetweenSwitches too long. Get Stats → SwitchesSuppressed tells you which. |
| It changes target constantly | The opposite: margin at 1.0 and no interval. That is the configuration this plugin exists to avoid. |
| A taunt does nothing | The taunted actor is not the one you think. ThreatTable.Dump prints the table; a taunt on an actor that is not in it is created automatically, so a wrong actor is the usual answer. |
| An enemy follows a player forever | ForgetBeyondDistance is 0 (disabled) or larger than the level. |
| Threat behaves differently at different frame rates | It should not — decay is per second. If you have replaced ApplyDecay, check it against ThreatTable.Decay.IsPerSecondNotPerCall. |
| The enemy attacks the healer immediately | HealingThreatMultiplier is doing exactly what it says. 0.5 of a large heal is still a large number. |