The Flaw of Decentralized Combat AI
When building group encounters for action combat titles, developers often delegate attack readiness directly to individual enemy finite state machines (FSMs) or behavior trees. An enemy identifies line-of-sight, evaluates distance to the player, checks its internal cooldown timer, and executes an attack swing. In a 1v1 duel, this logic holds up. In a melee skirmish with five or six aggressors, this decentralized architecture completely collapses. Independent entities inevitably converge on the player simultaneously, triggering unreadable frame-overlapping hitboxes that leave no defensive window for parries or dodges. Read technical details on project ronin action combat souls-like. Reference: UE5 GAS Melee Combo Tree Architecture in C++. Reference: Structuring Character State Machines in C++ for Action Combat. Reference: Souls-Like Parry Windows in Unreal Engine C++: An Architecture. Reference: Why UE5 Live Coding Crashes When You Edit Header Files.
At Outlore, when building Project Ronin inside our bespoke C++ engine, we treat group combat not as an agent decision problem, but as a centralized resource scheduling protocol. The player's cognitive bandwidth is finite. If four enemies initiate unblockables within a 150-millisecond window, the combat model breaks regardless of how well-tuned each agent's local state machine may be. Concurrency and cadence must be governed by an authoritative arbiter: a Combat Director managing a pool of consumable attack tokens. Read technical details on outlore game studio project ronin.
Architectural Overview: Token Pools and Weighted Budgets
A combat token system abstracts the right to execute an attack into discrete tickets issued by a manager tied to the target (usually the player). Rather than polling every AI agent to see if it wants to swing, the central director processes incoming acquisition requests and dispenses tokens based on availability, threat priority, and cooldown intervals. Read technical details on parry mechanics hit-stop input buffering combat design.
Naïve implementations treat all tokens as binary flags: an enemy holds one token, swings, and releases it. Hardcore action games require granular pacing. A quick jab might cost 1 token, while an evasive sweep or heavy overhead slam costs 2 tokens. Furthermore, tokens should be categorized by attack discipline—such as Melee, Ranged, and Flanking. If a heavy brute claims 2 of the 3 available melee tokens, only one minor grunt can execute a basic jab alongside him, while archers consume tokens from a separate ranged pool to maintain background tension without overcrowding the primary melee lane. Read technical details on studio story founder game development journey.
Data-Oriented C++ Implementation
Monolithic engines often build token systems across sprawling inheritance hierarchies, relying on virtual interfaces, actor components, and heap allocations per request. In a high-tick melee engine, pointer chasing across cache lines induces unnecessary stalls. We model the combat arbiter using compact, contiguous flat arrays that map directly onto fixed entity identifiers.
Below is a cache-aligned, zero-allocation C++ implementation of a token manager suitable for tightly budgeted combat loops:
#include <cstdint>
#include <cstddef>
#include <algorithm>
struct EntityID {
uint32_t id;
bool operator==(const EntityID& other) const { return id == other.id; }
bool operator!=(const EntityID& other) const { return id != other.id; }
};
static constexpr EntityID INVALID_ENTITY{ 0xFFFFFFFF };
enum class TokenType : uint8_t {
MeleeNormal,
MeleeHeavy,
Ranged,
Count
};
struct TokenLease {
EntityID holder{ INVALID_ENTITY };
TokenType type{ TokenType::MeleeNormal };
uint8_t cost{ 0 };
float remainingDuration{ 0.0f };
bool locked{ false };
};
class CombatTokenDirector {
public:
static constexpr size_t MAX_ACTIVE_LEASES = 8;
static constexpr uint8_t TOTAL_MELEE_BUDGET = 3;
static constexpr uint8_t TOTAL_RANGED_BUDGET = 2;
CombatTokenDirector() {
for (auto& lease : m_leases) {
lease.holder = INVALID_ENTITY;
}
}
bool RequestToken(EntityID requester, TokenType type, uint8_t cost, float maxDuration) {
if (cost == 0 || HasActiveToken(requester)) {
return false;
}
uint8_t currentUsage = GetCurrentUsage(type);
uint8_t budget = GetBudget(type);
if (currentUsage + cost > budget) {
return false;
}
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder == INVALID_ENTITY) {
m_leases[i].holder = requester;
m_leases[i].type = type;
m_leases[i].cost = cost;
m_leases[i].remainingDuration = maxDuration;
m_leases[i].locked = false;
return true;
}
}
return false;
}
void ReleaseToken(EntityID requester, float cooldownDuration) {
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder == requester) {
m_leases[i].holder = INVALID_ENTITY;
m_leases[i].locked = false;
ApplyCooldown(m_leases[i].type, cooldownDuration);
return;
}
}
}
void ForceRevoke(EntityID requester) {
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder == requester) {
m_leases[i].holder = INVALID_ENTITY;
m_leases[i].locked = false;
return;
}
}
}
void Update(float deltaTime) {
for (float& cd : m_typeCooldowns) {
if (cd > 0.0f) {
cd = std::max(0.0f, cd - deltaTime);
}
}
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder != INVALID_ENTITY) {
m_leases[i].remainingDuration -= deltaTime;
if (m_leases[i].remainingDuration <= 0.0f && !m_leases[i].locked) {
m_leases[i].holder = INVALID_ENTITY;
}
}
}
}
bool HasActiveToken(EntityID requester) const {
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder == requester) return true;
}
return false;
}
private:
uint8_t GetCurrentUsage(TokenType type) const {
uint8_t total = 0;
for (size_t i = 0; i < MAX_ACTIVE_LEASES; ++i) {
if (m_leases[i].holder != INVALID_ENTITY && m_leases[i].type == type) {
total += m_leases[i].cost;
}
}
return total;
}
uint8_t GetBudget(TokenType type) const {
if (m_typeCooldowns[static_cast<size_t>(type)] > 0.0f) return 0;
switch (type) {
case TokenType::MeleeNormal:
case TokenType::MeleeHeavy: return TOTAL_MELEE_BUDGET;
case TokenType::Ranged: return TOTAL_RANGED_BUDGET;
default: return 0;
}
}
void ApplyCooldown(TokenType type, float duration) {
m_typeCooldowns[static_cast<size_t>(type)] = duration;
}
TokenLease m_leases[MAX_ACTIVE_LEASES];
float m_typeCooldowns[static_cast<size_t>(TokenType::Count)]{ 0.0f };
};Stagger States, Timeouts, and Preemption Edge Cases
The most common failure mode in custom token systems is token leakage. If an enemy claims an attack token with a 3.0-second lease but gets interrupted on frame 4 by a player parry or stagger animation, that token cannot remain locked for the full duration. Doing so causes surrounding enemies to circle idle, frozen in an artificial standby queue while the combat pacing grinds to a halt.
To guarantee responsive feedback loops, token revocation must hook directly into your combat reaction events. In our pipeline, the instant an entity enters a hurtbox hit-reaction, stagger, knockdown, or death sequence, the engine broadcasts an immediate interruption to the director via `ForceRevoke(entityID)`. This recycles the token budget back into the contiguous pool without applying an artificial cooldown penalty to other waiting agents.
Conversely, you must implement timeouts against stalled AI paths. If an enemy receives a token but its pathfinding navigation node gets caught behind geometry or dynamic obstacles, the token lease will expire after `remainingDuration` elapses, preventing single agents from monopolizing the combat lane.
Spatial Scoring: Deciding Which Enemy Attacks Next
A combat director should not process token requests strictly through a first-come, first-served FIFO buffer. If an enemy positioned behind the player's camera grabs the sole melee token, the player gets attacked by off-screen threats while facing enemies standing directly in front of them idle passively.
Combat arbitration requires spatial and camera-aware heuristics. Prioritize ticket requests using a composite score based on three core parameters:
1. **Camera Dot Product:** Score higher for enemies within the player's primary view frustum (`dot(CameraForward, DirectionToEnemy) > 0.5`). Off-screen enemies receive lower scores or restricted token tiers to prevent visual blind-siding. 2. **Optimal Distance Window:** Weight enemies situated at their weapon's golden engagement radius. An enemy standing 1.5 meters away should yield a significantly higher claim priority than an agent running toward the player from 6 meters out. 3. **Starvation Timer:** Track time elapsed since an enemy's last completed swing. Incrementing a starvation bias prevents dominant front-line grunts from perpetually starving flanking enemies of attack windows.
Proximity Override Protocols
Always incorporate a proximity override. If the player rushes directly into an idling grunt's face to exploit its waiting state, strict token locking feels unnatural. When an agent without a token falls within a point-blank threshold (e.g., < 1.0 meter) for more than 500 milliseconds, it must be granted permission to trigger a low-damage retaliatory shove or quick defensive strike, bypassing the central pool to keep the combat space reactive.