18 minutes
Ticket Schedulers: Letting an External Scheduler Define a Gameplay Mechanic

Five enemies, one player, and a design rule: no more than two enemies attack at the same time.
The rule is short to say and awkward to place in code. Inside the enemy it becomes a loop over the other enemies. Inside a manager it becomes a gate that every enemy has to pass. The second placement also moves the mechanic itself. Once a single object hands out permission to attack, that object’s policy is the mechanic, and swapping the policy turns the same enemies into a duel, a pack, a boss whose heavy attacks never overlap, or a crowd that rarely talks over itself.
This post builds a small ticket scheduler in C#, runs one agent class through seven setups, and reads off what each does to a group of five. After that it looks at how Half-Life, Kingdoms of Amalur and a combat-token prototype handled the same job, and at the ways a scheduler goes wrong. The C# was compiled and run for this post, and every number quoted from the experiment is its real output. An agent here means any enemy or NPC that wants to act.
A group rule has no good home inside one agent
The obvious version puts the check in the enemy:
// Inside Enemy.Update
if (_state == State.Ready &&
squad.Members.Count(m => m.State == State.Attacking) < 2)
{
StartAttack();
}
It works for the first draft of the design and ages badly, for four reasons.
Every enemy type carries a copy of the rule and a reference to the group. A new enemy type means another copy, and changing the number means finding all of them.
The rule can only count. Give heavy attacks a bigger share of the allowance than light ones, add a pause after each attack, or raise the limit on hard difficulty, and each addition becomes one more condition threaded through every enemy.
Check and action are separate steps. If two enemies decide in the same frame before either one’s state has changed, for example because decisions run in parallel jobs, both read one attacker and both start.
Nothing decides who goes next. When a slot opens, whichever enemy is updated first takes it, and fairness is whatever the update order happens to give.
An external scheduler removes these problems by owning the rule. There is one place to read it, one place to change it, and one answer to the question of who goes next.
The contract
A ticket is permission to act. The scheduler offers three operations, and an agent needs nothing else: request a ticket with a cost, ask whether the ticket is held, and release it. In C# that is a small interface, shown at the top of the scheduler listing below. Everything else, including the capacity, the queue and the cooldowns, sits behind it.
The diagram shows the loop. Agents ask. The scheduler grants or defers. A granted agent acts, and a deferred agent does something useful while it waits. Release closes the loop. The designer’s tuning values, capacity and costs and gaps, live on the scheduler and away from every agent.
The agent only asks
The agent below attacks for five frames, recovers for three, then wants to attack again. It knows nothing about capacity or about other agents.
public enum AgentState { Idle, Waiting, Attacking, Recovering }
/// <summary>
/// An enemy that wants to attack. It never decides how many others may attack alongside it.
/// It asks for a ticket, does something sensible while it waits, and gives the ticket back when it is done.
/// </summary>
public sealed class Agent
{
readonly ITicketSource _tickets;
readonly int _cost, _attackTime, _recoverTime;
float _until;
public AgentState State { get; private set; }
public Agent(ITicketSource tickets, int cost = 1, int attackTime = 5, int recoverTime = 3)
{
_tickets = tickets; _cost = cost; _attackTime = attackTime; _recoverTime = recoverTime;
}
public void Think(float now)
{
switch (State)
{
case AgentState.Idle:
State = AgentState.Waiting;
goto case AgentState.Waiting;
case AgentState.Waiting:
_tickets.Request(this, _cost); // asking every frame is harmless: requests are idempotent
if (_tickets.Holds(this))
{
State = AgentState.Attacking;
_until = now + _attackTime;
}
// else: stay in cover, circle, flank. What a denied agent does is part of the mechanic.
break;
case AgentState.Attacking:
if (now >= _until)
{
_tickets.Release(this); // finished: the ticket goes back to the pool
State = AgentState.Recovering;
_until = now + _recoverTime;
}
break;
case AgentState.Recovering:
if (now >= _until) State = AgentState.Idle;
break;
}
}
/// <summary>Death, despawn, scene change: whatever ends the agent must also end its claim.</summary>
public void Die() => _tickets.Release(this);
}
Three details carry the design.
Requests are idempotent. A waiting agent asks every frame and does not need to remember whether it already asked, which removes a class of bookkeeping bugs.
Waiting is a behavior. The comment in the Waiting case marks the place where a denied agent takes cover, circles, flanks or taunts. An enemy that is refused and then stands still in the open reads as a bug, so what an agent does while it waits is part of the mechanic.
Release happens on finish and on death. Die hands back whatever the agent holds, and the same call withdraws a request that is still in line. Any event that ends the agent has to end its claim, including despawns and pooled objects returning to the pool.
The scheduler decides
using System;
using System.Collections.Generic;
/// <summary>What an agent needs to know about permission. The policy behind it is somebody else's business.</summary>
public interface ITicketSource
{
void Request(object agent, int cost); // idempotent: asking again changes nothing
bool Holds(object agent);
void Release(object agent); // safe to call when nothing is held
}
/// <summary>
/// Hands out a limited budget in first-come, first-served order.
/// Agents ask and wait; the scheduler decides who may act and when.
/// </summary>
public sealed class TicketScheduler : ITicketSource
{
struct Pending { public object Agent; public int Cost; }
readonly List<Pending> _queue = new();
readonly Dictionary<object, int> _held = new();
/// <summary>Total cost allowed in flight at once. Changing this at runtime is the difficulty dial.</summary>
public int Capacity { get; set; }
public int InUse { get; private set; }
public TicketScheduler(int capacity) => Capacity = capacity;
public bool Holds(object agent) => _held.ContainsKey(agent);
public void Request(object agent, int cost = 1)
{
if (_held.ContainsKey(agent) || _queue.Exists(p => p.Agent == agent)) return; // keeps the original place in line
_queue.Add(new Pending { Agent = agent, Cost = cost });
}
/// <summary>Call on finish, plan change, death or despawn. Also drops a request that is still waiting.</summary>
public void Release(object agent)
{
if (_held.TryGetValue(agent, out var cost)) { _held.Remove(agent); InUse -= cost; }
_queue.RemoveAll(p => p.Agent == agent);
}
/// <summary>Run once per frame after agents have asked. They see the result on their next update.</summary>
public void Tick()
{
while (_queue.Count > 0)
{
// A request larger than the whole budget would block the line forever, so clamp it.
int cost = Math.Clamp(_queue[0].Cost, 1, Math.Max(1, Capacity));
if (InUse + cost > Capacity) break; // strict order: the head of the line waits for room
_held[_queue[0].Agent] = cost;
InUse += cost;
_queue.RemoveAt(0);
}
}
// Simplification: the scans are O(n), which is fine for dozens of agents.
// For hundreds, track queued agents in a HashSet.
}
Capacity is a budget in cost units. Requests join a queue in arrival order, and Tick walks the queue from the front, granting tickets while there is room. The agent at the front of the line waits for room and smaller requests behind it do not overtake, which keeps the order fair and carries a cost that a later section covers. Asking again keeps an agent’s original place in line. A request larger than the whole budget is clamped to the budget, since an unservable request at the front would block the line permanently.
Tick runs once per frame, after agents have asked. Agents see the result on their next update, so a grant takes effect one frame after the request. Frame-exact timing needs to account for that.
Same agent, seven setups
The experiment runs five agents for 1,000 frames. Each attacks for five frames and recovers for three, and agents are updated in the same order every frame. A turn is one trip into the attack state. Only the object behind ITicketSource changes between runs.
no scheduler : peak 5 turns per agent [112, 112, 112, 112, 112]
capacity 2 : peak 2 turns per agent [84, 83, 56, 56, 55]
2, refuse and retry : peak 2 turns per agent [75, 75, 74, 74, 74]
capacity 1 : peak 1 turns per agent [34, 34, 33, 33, 33]
weighted (3,3 heavy) : peak 3, heavies at once 1, max budget in use 4/4 turns [84, 83, 56, 56, 55]
capacity 1 -> 3 : peak first half 1, second half 3
cooldown gate (gap 4): peak 1, shortest quiet gap 4 frames turns [23, 22, 22, 22, 22]
capacity 2, identical timers, by group size (fewest turns / most turns):
3 agents: 1.00 [100, 100, 100]
4 agents: 0.99 [84, 84, 83, 83]
5 agents: 0.65 [84, 83, 56, 56, 55]
6 agents: 0.98 [56, 56, 56, 56, 55, 55]
7 agents: 0.73 [56, 56, 55, 42, 42, 42, 41]
8 agents: 0.98 [42, 42, 42, 42, 42, 42, 41, 41]
scheduler checks passed
Reading the lines from the top:
The control has no scheduler. All five agents attack together, which is the problem the rule exists to solve.
Capacity 2 allows two attackers at once and queues the rest. Every agent gets turns, though not evenly, and a later section looks at the shares. In play this is pack pressure with pauses: enough attackers to feel threatened and few enough to read.
Refuse and retry replaces the queue with a pool that grants immediately when there is room and drops the request otherwise. A refused agent asks again on the next frame. The peak is again two, and in this run the turns came out more evenly (75, 75, 74, 74, 74).
Capacity 1 gives a duel-like rhythm: one attacker at a time, everyone else waiting a turn.
The weighted run gives three agents a cost of 1 and two agents a cost of 3, under a budget of 4. The peak of three attackers can only be the three light agents together, because a heavy agent plus two others would cost more than 4. The two heavy agents are never active at the same time, and the budget never rose above 4. Costs let one pool say that one attack takes up more of the encounter than another.
The dynamic run starts at capacity 1 and raises it to 3 at frame 500. The peak goes from 1 to 3 and no agent changed. That is the difficulty and pacing dial: a boss phase, an escalation after the player’s first kill, or a difficulty setting can all adjust a single number at runtime.
The cooldown gate is a different class behind the same interface. It allows one holder at a time and enforces a quiet gap after each release. With a gap of four frames, the shortest stretch with nobody attacking measured four frames. The same shape suits dialogue lines, voice barks, telegraph spacing and any action that needs breathing room more than it needs a head count.
Both alternative policies are short:
using System.Collections.Generic;
/// <summary>
/// A different policy behind the same interface: one holder at a time, and a quiet gap after each release.
/// This is the shape of a "somebody is already talking" gate.
/// </summary>
public sealed class GlobalCooldown : ITicketSource
{
readonly float _gap;
readonly List<object> _queue = new();
object _holder;
float _readyAt, _now;
public GlobalCooldown(float gap) => _gap = gap;
public bool Holds(object agent) => _holder == agent;
public void Request(object agent, int cost)
{
if (_holder == agent || _queue.Contains(agent)) return;
_queue.Add(agent);
}
public void Release(object agent)
{
if (_holder == agent) { _holder = null; _readyAt = _now + _gap; }
_queue.Remove(agent);
}
public void Tick(float now)
{
_now = now;
if (_holder == null && _queue.Count > 0 && now >= _readyAt)
{
_holder = _queue[0];
_queue.RemoveAt(0);
}
}
}
using System.Collections.Generic;
/// <summary>
/// The no-queue alternative: a request is granted on the spot if there is room, otherwise it is dropped.
/// A refused agent has to ask again later.
/// </summary>
public sealed class ImmediatePool : ITicketSource
{
readonly HashSet<object> _held = new();
readonly int _capacity;
public ImmediatePool(int capacity) => _capacity = capacity;
public bool Holds(object agent) => _held.Contains(agent);
public void Request(object agent, int cost)
{
if (_held.Count < _capacity) _held.Add(agent);
}
public void Release(object agent) => _held.Remove(agent);
}
The Agent class is the same file in all seven runs. Swapping the object behind ITicketSource is the strategy pattern applied to a rule, and the scheduler resembles a mediator in that agents know it and know nothing about each other. In a behavior tree the request can sit in a condition node, the kind the behavior trees post introduces. In GOAP it is a precondition on the attack action, and a blackboard is a natural place to publish who holds what while debugging.
How shipped and published designs do it
Half-Life: slots on the squad leader
Valve’s released Half-Life SDK keeps its squad tickets in an integer. A squad is a leader and up to four followers (MAX_SQUAD_MEMBERS is 5), and the leader’s m_afSquadSlots field is the pool, one bit per ticket. Human grunts have two engage bits and two grenade bits. Other monster types define their own bits in the same mask, and one extra bit marks a squad whose members are fighting different enemies.
OccupySlot takes the set of bits a soldier would accept and returns whether it got one. A soldier outside any squad always succeeds. When squad members are fighting different enemies it also always succeeds, and the source comment gives the reason: otherwise every attack slot could be held by soldiers busy with other targets, stranding a soldier who cannot engage its own. The scheduler switches itself off when its assumption of one shared enemy stops holding.
The soldier’s schedule selection treats the answer as a preference order. A grunt that can see the player and could fire tries an engage slot, then a grenade slot, then takes cover. The comments read “try to take an available ENGAGE slot”, “throw a grenade if can and no engage slots are available” and “hide!”. A refusal is routed to a different behavior, which is the waiting branch of the C# agent.
Tickets come back through lifecycle hooks. The base monster class calls ScheduleChange whenever the current schedule becomes invalid or the monster’s state changes, and the squad override vacates the slot. Killed vacates it too. No code returns a ticket at the end of an individual burst, so plan change and death are the only exits.
Structurally this is the refuse-and-retry pool from the experiment: no queue, grants on the spot, and a denied soldier gets another chance the next time its schedule is chosen. Two other rules in the same files have the scheduler shape. The code that decides whether a soldier may speak keeps one global timestamp. A soldier that speaks pushes the timestamp 1.5 to 2.0 seconds ahead, and no grunt may speak until it passes, which is the cooldown gate applied to dialogue. And an “enemy eluded” flag, set when nobody in the squad has seen the player for more than five seconds, makes the next soldier who finds an unaware player stop and signal first. The source comment describes the purpose as giving the player a chance to turn.
Kingdoms of Amalur: a stage manager with weights
Michael Dawe’s Game AI Pro chapter describes the system used in Kingdoms of Amalur: Reckoning, called Belgian AI inside the studio. Every creature has a grid weight, every attack has an attack weight, and each target, in practice the player, has a grid capacity and an attack capacity. A creature asks a central stage manager for a spot, and the request succeeds when the creature’s weight fits in the remaining capacity. In the chapter’s example a soldier of weight 4 and a troll of weight 8 fill a capacity of 12, so a second soldier waits outside the grid. Attack weights work the same way for single attacks. With an attack capacity of 10, a troll’s charge (weight 6) leaves 4, so a soldier cannot use a lunge (5) and settles for a sword swing (3).
Several of the chapter’s points line up with the C# above. Creatures give their slot back immediately after launching an attack, so the stage manager can queue requests and rotate which creatures attack, and in a small encounter an attacker that just finished may be assigned again at once. Difficulty scales by raising the capacities while leaving each creature’s weights alone, which is the dial from the 1-to-3 run. Cooldowns on individual, creature-wide and global bases limit repeats of the same attack, because capacity alone lets many creatures launch the same area attack together at higher difficulty. The stage manager can also steal a slot from one creature and give it to another that has moved into a better position, while creatures in the middle of an attack lock their assignment so it cannot be taken mid-swing. The chapter names the centralization of positioning logic in the stage manager as the main strength of the approach.
Tokens in a prototype, and in DOOM
Sam Bloomberg’s 2021 write-up of a prototype combat system approaches tokens from the target’s side. A token source component sits on every character that can be targeted. An attacker tells it a value that reflects how big the attack is and receives a place in line. The source has a maximum total value, and once that is reached, attackers wait in a queue and are notified as value frees up. A cooldown multiplier makes released value available again gradually, so attacks do not chain without a break. A look-ahead setting lets the source skip past a request that is too large for the room left, which addresses the head-of-line problem described below.
In his prototype, eight enemies that had all attacked at once became at most three attackers, usually one or two. Bloomberg describes the system as early and still in need of tuning. He also writes that DOOM (2016) uses tokens that enemies can steal from each other, and that he has seen the technique called combat tokens and, in a related form, a kung-fu circle. He says plainly that he is unsure how widespread it is.
Where schedulers go wrong
Leaked tickets. An agent that despawns, returns to a pool or changes scene while holding a ticket takes the ticket with it. The scheduler shrinks by one slot per leak and nothing announces it. The test program checks that a death while attacking frees the ticket for the next agent in line and leaves the budget at zero, and pooled objects need the same release in their reset path.
Head-of-line blocking. Strict first-come order protects fairness and sets a trap. One test uses a budget of three with one unit in use and a cost-3 request at the front of the line. The cost-1 request behind it would fit, but it is not granted, because the front request waits for room. Look-ahead, as in Bloomberg’s prototype, skips such requests at the price that a heavy request can starve if small ones keep jumping ahead of it.
Uneven shares. A queue does not guarantee even turns. With capacity 2 and identical timers, the group-size sweep gave these results, each showing the fewest turns divided by the most:
- 3 agents: 1.00
- 4 agents: 0.99
- 5 agents: 0.65
- 6 agents: 0.98
- 7 agents: 0.73
- 8 agents: 0.98
At five agents the turns were 84, 83, 56, 56 and 55. Nobody starved, yet two agents took about one and a half times as many turns as the other three. The sweep points at an interaction between equal timers and a fixed update order, which this experiment did not examine further. Real enemies have varied timers, and that variation may hide the effect. The practical step is to log turns per agent in a test and treat the first run as a hypothesis. Randomized delays, priority for the agent that has waited longest, or a rotating start position are candidates, and the experiment did not try any of them.
Capacity changes do not evict. Lowering capacity leaves current holders in place. The test program checks that three holders remain holders after the capacity drops to 1 and that nobody new is admitted until usage falls. That suits difficulty changes, since no attack is cancelled mid-swing. A mechanic that has to cut in immediately needs an explicit revoke path.
The pool has an owner. Half-Life keeps each pool on its squad leader, so two squads in view can each field their own attackers. Amalur and Bloomberg keep the budget on the target, so the limit follows the player. Which one fits depends on the mechanic, and the choice is hard to change after the code has spread.
Practical takeaway
Put the scheduler behind a three-method interface in the first prototype, start with the queue at a capacity that feels right for the encounter, and give every denied state a visible behavior. Release in every exit path, then log turns per agent over a few minutes of play. Add costs when two attacks clearly deserve different weights. Add a cooldown gate when the spacing between actions is the thing to control, and the head count is secondary.
References
- Valve Half-Life SDK: squadmonster.h, squadmonster.cpp, hgrunt.cpp and schedule.cpp. Only short code comments are quoted here, for commentary.
- Beyond the Kung-Fu Circle: A Flexible System for Managing NPC Attacks, Michael Dawe, Game AI Pro.
- Realtime turn-based AI, Sam Bloomberg.