13 minutes
Attack Tickets in Game AI: How Half-Life’s Squads Take Turns

Five soldiers, one player, and a rule that sounds unfair to the soldiers: no more than two of them may shoot at the same time.
Valve’s released Half-Life SDK shows how the original game’s human grunts follow that rule. There is no director object and no difficulty dial in the way. Each squad owns a few bits in an integer, a soldier has to claim one before it attacks, and it hands the bit back when its plan changes or it dies. Soldiers without a bit take cover, throw a grenade, or hold position. From the player’s side that waiting reads as tactics.
This post walks through how the mechanism works in the released C++, rebuilds the idea in about fifty lines of C#, and measures what it does in a small simulation, including a failure the obvious version has. Everything said about Valve’s code comes from reading squadmonster.h, squadmonster.cpp and hgrunt.cpp in the ValveSoftware/halflife repository. The C# is a model of the idea, not a port.
Why an unlimited squad is a problem
Imagine every soldier fires whenever it has a clear line to the player. Damage per second then scales with the number of soldiers who happen to see you. Five shooters deal two and a half times the damage of two, assuming equal weapons, and the player has no way to read or plan around that number. A corridor fight becomes a question of how many enemies rolled into view on the same frame.
The visual result is also poor. Five muzzle flashes at once look like a wall of noise, and a player cannot tell which enemy to deal with first.
A cap fixes both problems and gives the designer one number to tune. The remaining question is how to enforce it without a central controller that every enemy has to ask permission from. Half-Life’s answer is small enough to read in an afternoon.
A squad is a leader and a bitmask
In the released code a squad is at most five monsters. MAX_SQUAD_MEMBERS is 5, and the leader stores handles for the other four. A soldier that is capable of squad behavior and not yet in one tries to recruit: it looks for monsters of the same classification within a radius of 1024 units and adds up to four of them.
The leader holds the state the whole squad shares. That includes the member list, the last known position of the enemy, the last time anyone saw the enemy, and an integer called m_afSquadSlots. That integer is the pool of tickets, stored as a set of flags.
The header defines the flags per species, all in the same mask:
- Human grunts have two engage slots and two grenade slots.
- Alien grunts have two hornet slots and a chase slot.
- Houndeyes have three attack slots.
- One global flag marks a squad whose members are not all fighting the same enemy.
That adds up to eleven bits. Any monster that forms squads can define its own tickets and claim them with the same code.
Taking a ticket
Claiming happens in OccupySlot, which receives the set of slots the caller would accept, such as both engage bits. It works through four cases:
- A monster that is not in a squad succeeds immediately. A lone soldier has nobody to take turns with.
- If the squad’s members are fighting different enemies, the monster records the special split slot and succeeds. The source comment explains why: with split enemies, attack slots could all be held by soldiers busy with someone else, which would strand a soldier unable to engage its own target. So the cap switches itself off.
- Otherwise the function walks the acceptable bits and takes the first one that is free on the leader’s mask, remembering which bit it took.
- If no acceptable bit is free, it returns false.
The function does not decide what a failure means. The caller does, which is where the interesting behavior lives.
The order of preference
When a grunt can see its enemy and could shoot, the schedule selection in hgrunt.cpp asks a short series of questions. Grunts armed with a grenade launcher get an earlier branch of their own that tries a grenade ticket first. For the rest, the order is:
The three outcomes at the bottom are labeled in the source by short comments: “try to take an available ENGAGE slot”, then “throw a grenade if can and no engage slots are available”, and finally “hide!”. A soldier that fails both ticket checks takes cover.
When the soldier cannot see the player, the same idea applies with different outcomes. It tries a grenade ticket first, since a grenade can go over a wall. Failing that, it tries an engage ticket and uses it to move toward a spot with a line of fire. Failing both, it stays put for a couple of seconds in case the player wanders back into view, and may play a taunt line.
One detail from that second case matters for the design. The engage ticket is held while the soldier is moving, not only while it is firing. A ticket is permission to be aggressive, and the squad’s aggression is what the pool limits.
Returning tickets
A pool that only hands out tickets would run dry in seconds. Two code paths give them back.
The first is ScheduleChange, which the base monster class calls from its schedule maintenance whenever the current schedule becomes invalid or the monster’s state changes. The squad override calls VacateSlot, which clears the soldier’s bit on the leader’s mask. Because the base class calls this before choosing the next schedule, the ticket is free by the time the same soldier, or a squadmate, picks its next plan.
The second is Killed. A dying soldier vacates its slot and removes itself from the squad before the normal death handling runs.
Release is tied to lifecycle events that already exist, plan change and death, and not to each individual attack. Nobody has to remember to give a ticket back at the end of a burst, so a ticket can only leak through a bug in the lifecycle itself.
Waiting looks like tactics
Soldiers without a ticket still act. They take cover, hold a standoff, or throw something from the separate grenade pool. A player watching from the other side sees a group that pushes, hides and rotates, and has no reason to suspect that two integers are doing the rotating.
The code adds one more courtesy on top. If nobody in the squad has seen the player for more than five seconds, a member sets an “enemy eluded” flag on the leader. When a soldier then finds the player again and the player is not facing it, the soldier plays a signal animation and turns toward the player instead of opening fire. The source comment describes the purpose as wasting a little time to give the player a chance to turn. The flag clears once a soldier uses it, and again when the squad acquires a new enemy.
This is a fairness rule, separate from the cap, and the two stack. The cap limits how many soldiers attack at once. The pause limits how fast the first attack lands after a reunion.
Tickets are one of several brakes
It would be wrong to credit the ticket pool alone with the feeling of “never more than two guns”. Several other gates apply:
- A soldier only fires its main weapon within 2048 units and when the target is inside a forward arc.
- A friendly-fire check builds a box in front of the soldier and blocks the shot if a squadmate is inside it.
- After a grenade throw, the soldier waits six seconds before it even checks whether it can throw another. Launcher grunts on the hardest skill level wait a random two to five seconds instead.
The pool counts are also not strictly “two attackers”. Engage and grenade tickets are separate, so a squad can have two soldiers on engage tickets and two more on grenade tickets simultaneously. And since the mask lives on each squad’s leader, the cap applies per squad. Two squads in view of the player can put four soldiers on engage tickets. Both observations follow from reading where the mask is stored and how the bits are defined, and neither depends on testing the game.
The same idea in C#
The model below keeps the structure and drops the engine. The capacity is per ticket kind. Members are tracked by identity, so in Unity the key would be a component. The decision follows the same order as the original: shoot if a ticket is free, otherwise throw if a grenade ticket is free, otherwise take cover.
using System.Collections.Generic;
public enum SlotKind { Shoot, Grenade }
public enum Plan { None, Shoot, Grenade, TakeCover }
/// <summary>
/// A squad-wide pool of attack tickets. A member must hold a ticket to attack and gives it
/// back when its plan changes or it dies.
/// </summary>
public sealed class SquadSlots
{
readonly Dictionary<SlotKind, int> _capacity;
readonly Dictionary<object, SlotKind> _held = new();
readonly Dictionary<object, int> _blockedUntil = new();
readonly int _reissueDelay;
/// <summary>When squad members fight different enemies the cap is switched off.</summary>
public bool EnemiesSplit { get; set; }
public SquadSlots(int shoot = 2, int grenade = 2, int reissueDelay = 0)
{
_capacity = new() { [SlotKind.Shoot] = shoot, [SlotKind.Grenade] = grenade };
_reissueDelay = reissueDelay;
}
public int Held(SlotKind kind)
{
int n = 0;
foreach (var k in _held.Values) if (k == kind) n++;
return n;
}
public bool TryAcquire(object member, SlotKind kind, int now)
{
if (EnemiesSplit) return true; // nobody gets stranded behind someone else's ticket
if (_held.TryGetValue(member, out var have) && have == kind) return true;
if (_blockedUntil.TryGetValue(member, out var until) && now < until) return false;
_held.Remove(member); // a new plan gives up the old ticket first, no cooldown
if (Held(kind) >= _capacity[kind]) return false;
_held[member] = kind;
return true;
}
public void Release(object member, int now)
{
if (_held.Remove(member) && _reissueDelay > 0)
_blockedUntil[member] = now + _reissueDelay; // stops one member re-taking its own ticket forever
}
/// <summary>Death or despawn: free the ticket with no cooldown, the member is gone.</summary>
public void Remove(object member)
{
_held.Remove(member);
_blockedUntil.Remove(member);
}
}
public static class GruntBrain
{
// Same order as the original: shoot if a ticket is free, else throw a grenade, else hide.
public static Plan Decide(SquadSlots slots, object member, bool canThrow, int now)
{
if (slots.TryAcquire(member, SlotKind.Shoot, now)) return Plan.Shoot;
if (canThrow && slots.TryAcquire(member, SlotKind.Grenade, now)) return Plan.Grenade;
return Plan.TakeCover;
}
}
Three choices in this code matter.
TryAcquire releases the member’s old ticket before taking a new one, and answers true when the member already holds the right kind. In the original that job falls to ScheduleChange running first. Folding it into the acquire call makes the class safer to use from code that does not have such a lifecycle hook.
Remove frees a ticket with no cooldown, because a dead or despawned member cannot misuse it. Release can apply a cooldown, which is the next section’s subject.
EnemiesSplit mirrors the original’s escape hatch. When members are not fighting the same target, the cap is off, so nobody is stranded.
What a simulation shows
A cap that works on paper can still behave badly, so the model got a small simulation. Five grunts run for 1,000 ticks. Each shoots for five ticks, then takes cover for three, then asks for a ticket again. Grunts are processed in the same order every tick. The test loop counts the largest number of simultaneous shooters and how many ticks each grunt spent shooting. Here is the actual output:
no limit : peak shooters 5, shots per grunt [1000, 1000, 1000, 1000, 1000]
2 tickets : peak shooters 2, shots per grunt [1000, 1000, 0, 0, 0]
2 tickets+delay : peak shooters 2, shots per grunt [295, 295, 295, 295, 292]
The first line is the control: without a limit, all five shooters fire at once.
The second line is the surprise. The cap holds, with a peak of two shooters, but the first two grunts release their tickets and re-take them on the same tick, before anyone else is asked. The other three never shoot. The cap did its job and the squad still behaves badly, because two soldiers fight while three hide indefinitely.
The third line adds a short reissue delay: a member that just released a ticket cannot take one again for two ticks. Every grunt now gets about the same share, within a few ticks of each other, and the peak stays at two.
Two caveats apply. The starvation here comes partly from the fixed processing order, which is an artifact of this loop. Valve’s code has no reissue delay that turned up in the files read for this post. The soldiers’ schedules end at different times and the ordering has natural variation, so the real game may never show the problem. This was not tested. The lesson for a new implementation is narrower: if your soldiers decide in a fixed order and your plans are short, expect the first in line to hold the tickets, and add something to break the pattern.
A cooldown is the smallest fix. Other options are a first-in, first-out queue, a random pick among the soldiers asking, or priority for the soldier that has waited longest.
Other games, other names
The pattern shows up in several shipped games under different names.
- Kingdoms of Amalur: Reckoning is described in a Game AI Pro chapter on what it calls Belgian AI. Every creature carries a grid weight, the cost of occupying a spot on the player’s grid. A grid capacity limits how many creatures can attack at once, and a separate attack capacity limits how many attacks of each type are allowed. The weights let a heavy hitter use more of the budget than a light one.
- DOOM (2016) uses tokens, per a Game Developer write-up of its AI. Each attack type has a limited number of tokens, a demon requests one before attacking and releases it afterward, and the counts change by difficulty level. Demons can also steal tokens from each other when one is better placed to use it.
- Sam Bloomberg’s prototype write-up describes tokens with numeric values and a maximum total, a queue of waiting enemies, and a cooldown before released value becomes available again.
Compared with Half-Life, these versions add weights, queues and stealing. The core is the same: attacking requires a scarce resource, and the pool is the designer’s dial.
Where this goes wrong
Four failure modes recur.
Leaked tickets. A ticket held by a soldier that despawned, was teleported, or changed scene is gone from the pool forever. The squad gets quieter and nobody notices why. Release on every exit path, and give yourself a debug overlay that shows which member holds which ticket.
Starvation. The simulation above is the example. Watch shots per member in a test, not only the peak.
The wrong owner. Valve keeps the pool on the squad leader, so two squads multiply the cap. Some systems keep the pool on the target instead, so the limit follows the player. Decide which one your design needs before the code spreads.
Hidden waiting. A soldier without a ticket needs something believable to do. If it stands still in the open, the player reads that as a bug. Cover, repositioning, a grenade pool, or a standoff animation turn a refused ticket into behavior.
The pool also fits cleanly into other AI structures. In a behavior tree it is a condition node. In GOAP it is a precondition on the attack action. In the blackboard model it is a small piece of shared squad state with clear ownership. The behavior trees post covers where a node like this plugs in.
Practical takeaway
Add one SquadSlots object per group of enemies, give each attack kind a capacity of two to start with, and make “plan changed” and “died” the only places tickets are returned. Refuse the ticket and send the enemy to cover, not to idle. Then log shots per member over a minute of play. If one member has most of them, add a cooldown or a queue before touching the capacity number.
References
- Valve Half-Life SDK: squadmonster.h, squadmonster.cpp and hgrunt.cpp. Only short code comments are quoted here, for commentary.
- Beyond the Kung-Fu Circle: A Flexible System for Managing NPC Attacks, Game AI Pro.
- Cyber Demons: The AI of DOOM (2016), Game Developer.
- Realtime turn-based AI, Sam Bloomberg.