< Return to Engineering Blog
Engine ArchitectureOCT 10, 2026 // 7 MIN READ

Combat Token System in C++: Multi-Enemy AI Pacing

Build a cache-friendly combat token system in C++ to prevent simultaneous enemy attacks and balance aggressive melee pacing without frame drops.

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.

DIAGRAM // ARCHITECTURE
SYS.COMBAT_AI // TOKEN_ARBITER DISPATCH_CYCLE: 16.6ms [TICK #4289] STATUS: ACTIVE ENEMY AI POOL N=6 AGENTS AIAgent_01 ATTACKING [HOLD_TKN] AIAgent_02 ENQUEUED [REQ_TKN] AIAgent_03 ATTACKING [HOLD_TKN] AIAgent_04 FLANKING / HOLD REQ: TokenArbiter:: ReserveToken(EType) TRY_ACQUIRE TOKEN ARBITER (C++) ARBITRATION LOGIC • Distance & Attack Arc • Max Concurrency Limit WAIT QUEUE (RING BUFFER) HEAD [0]: AIAgent_02 (P: 1) WAIT [1]: AIAgent_05 (P: 2) EMPTY [2] CAPACITY : 2 MELEE | 1 RANGED POLICY : LEASE TIMEOUT (1.5s) THREAD : MAIN GAME THREAD GRANT TOKEN BUCKET [2/3] TKN_MELEE_01 [LOCKED] OWNER: AIAgent_01 0.9s / 1.5s TKN_MELEE_02 [LOCKED] OWNER: AIAgent_03 0.4s / 1.5s TKN_RANGED_01 [FREE] READY FOR DISPATCH std::unique_ptr<TokenLease> AUTO-RELEASE ON DTOR EXECUTE PLAYER TARGET HIT SURFACE INCOMING ATTACKS 2 CURRENT / MAX 3 STATE MACHINE: ► AGENT_01: SWING ► AGENT_03: THRUST RAII LEASE EXPIRED // RETURN TOKEN COOLDOWN / CIRCLE TARGET (NO TOKEN) MEM: TOKEN_POOL 1.2KB SYNC: ZERO-ALLOC RUNTIME OVERFLOW: SUPPRESSED (ATTACK SLOTS CAPPED) SYS_HEALTH: OPTIMAL
// Figure 1: Architectural pipeline and execution flow for how to prevent multiple enemies attacking at once combat token system c++.

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:

cpp
#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.

KEY ARCHITECTURAL TAKEAWAYS

  • 01.Treat group combat coordination as a centralized resource scheduling problem rather than decentralized agent-level AI state checks.
  • 02.Implement weighted token costs and distinct pools (e.g., melee vs. ranged) to naturally balance heavy moves, light strikes, and projectile support.
  • 03.Eliminate heap allocations and pointer indirection by storing token leases in flat, contiguous, cache-aligned arrays updated inside the main combat loop.
  • 04.Enforce immediate token reclamation during stagger, hit-stun, knockdown, and death events to prevent deadlocks and idle AI pacing.

FREQUENTLY ASKED QUESTIONS

How do you prevent tokens from leaking if an enemy is interrupted mid-animation?

Bind the token revocation call directly to the entity's damage-receiving interface or state entry hook for stagger, knockdown, and death states. When an entity enters any hit-reaction state, fire ForceRevoke(entityID) immediately to return the allocated budget to the pool without triggering an attack cooldown penalty.

Should tokens be managed on the player entity or by a global director?

Tokens should be owned by the target entity being attacked (typically the player or a designated ally), not a single global singleton. This architecture supports multiple simultaneous factions or companion NPCs, allowing each target to govern its own threat budget independently.

How do you handle multi-enemy attacks like synchronized double-strikes?

Design synchronized attacks as composite actions that reserve multiple token slots at once, or use an explicit combo token type with a high cost (e.g., 2 or 3 tokens). The director verifies the complete cost is available before granting the lease to both collaborating entities.

Does a token system make combat feel turn-based or overly scripted?

Only if tuned with excessive global cooldowns or binary 1-token limits. By utilizing fractional token costs, brief inter-attack cooldowns, separate pools for ranged and melee archetypes, and proximity overrides, group combat maintains an aggressive, chaotic feel while remaining readable.

// CONNECTED_ENGINEERING_DOSSIERS

All Articles >