Summary

Tactical Position Selection (TPS) is the problem of choosing the best position from which an AI agent should act — advance, take cover, flank, regroup, or investigate. It is one of the most pervasive problems in shooter AI, solving situations from “which cover point should I run to?” to “where should the squad push to cut off the player?“.

The canonical architecture, described by Jack (Crysis 2 / CryEngine, Chapter 1, see source-game-ai-pro-360-tactics-strategy) and extended by Johnson (Chapter 14), structures position selection as a query language with three phases: generation, filtering, and weighting. A domain-specific language on top of Lua or similar scripting makes queries readable, designer-friendly, and fast to iterate.

“Tactical Position Selection is a keystone of shooter AI and a potential Swiss army knife of AI and gameplay programming.” — Jack, Chapter 1


Key ideas

  • Three-phase pipeline: Every TPS query generates a set of candidate positions, filters out invalid ones, then scores the remainder to find the best.
  • Scripted query language: Queries are written as readable scripts; the underlying engine evaluates them. Separating query authoring from query execution lets designers iterate without touching C++/C#.
  • Cover as geometry: Cover is not a single boolean property — it is directional. Line segments representing cover edges (cover segments) encode exactly where and in which direction cover is effective.
  • Pathfinding integration: TPS and pathfinding are closely coupled. The best position is not always the most tactically desirable one in isolation — it must be reachable, reachable first, and ideally reachable under cover.
  • Performance: With potentially hundreds of candidate points, TPS must be lazy, asynchronous, and budget-aware.

In practice

Three-phase query structure

Query
├── Generate: produce N candidate positions (NavMesh sampling, cover point list, etc.)
├── Filter / Conditions: discard positions that fail hard constraints
│       e.g. isVisible(position, enemy) == false
│            canReach(position) == true
│            distanceTo(position) < maxRetreatDistance
└── Weight: score surviving positions by soft preferences
        e.g. nearestToSquadCentre(position) × 0.3
           + distanceFromEnemy(position) × 0.5
           + coverQuality(position) × 0.2

Conditions return a boolean — the candidate is accepted or discarded. Weights return a float — scores are multiplied together (so a weight of 0.0 on any criterion rejects the point, similar to a condition).

Criteria object

Each condition or weight is wrapped in a criteria object that bundles the evaluation function with its parameters. This is the unit of reuse. Example criteria:

CriterionPhaseDescription
isVisible(puppet, target)FilterDoes the position have line-of-sight to target?
canReachBefore(puppet, rival)FilterCan puppet reach this position before rival?
directness(puppet, target)WeightHow directly does this position approach target?
coverQuality(puppet, enemy)WeightHow much of this position is covered from enemy?
distanceFromSquadCentre(squadCentre)WeightProximity to squad tactical centre
notOccupied()FilterNo ally already holds this position

In Crysis 2, criteria are named objects defined in Lua script. The query system is a small DSL embedded in Lua that enumerates candidates and evaluates criteria objects against each one.

The directness metric

Directness measures how directly a position advances toward the goal compared to the agent’s current position. For a query that should find an advance position:

directness = dot(normalise(candidate - agent), normalise(target - agent))

A value of 1.0 means the candidate is directly between the agent and target; −1.0 means it is directly behind the agent. Used as a weight, this naturally biases results toward positions that make progress toward the objective.

canReachBefore condition

A key condition for contested positions: “can I reach this position before my opponent can?”

bool CanReachBefore(NavMeshAgent puppet, NavMeshAgent rival, Vector3 candidate)
{
    float myTime   = NavMesh.CalculatePathLength(puppet.position, candidate) / puppet.speed;
    float rivalTime = NavMesh.CalculatePathLength(rival.position, candidate) / rival.speed;
    return myTime < rivalTime;
}

This prevents agents from running to positions the enemy will reach first.


Cover representation

Cover segments (CoverMap)

Cover is represented as line segments — short line segments aligned to the world geometry that mark where an agent can take cover. Each segment is associated with a NavMesh polygon via a CoverMap lookup.

Benefits over point-based cover systems:

  • A segment naturally encodes the direction the cover faces and its length.
  • The CoverMap provides O(1) lookup of cover segments near any NavMesh polygon.
  • No ray-casts are needed to query cover availability during path search.

Tactical pathfinding with cover bias

Standard A* treats all path segments equally. Tactical A* adds a cover-bias factor to each segment cost:

segmentCost = baseCost + α × exposedLength(segment, enemy, coverSegment)

Where exposedLength is computed by clipping the path segment against a frustum built from:

  • The cover segment’s line
  • The enemy’s position

The frustum approximates the “danger zone” in which the agent would be visible to the enemy while traversing this path segment. Longer exposed segments cost more, so the planner naturally routes through cover.

This produces cover-hugging movement without any explicit “find cover then move” logic — the path itself prefers covered routes.


Performance

Binary heap with lazy evaluation

With hundreds or thousands of candidate positions, evaluating all criteria for all candidates is expensive. A binary heap (priority queue) enables lazy evaluation:

  1. Insert all candidates into the heap with an initial rough score (from a cheap heuristic).
  2. Pop the top candidate.
  3. Evaluate the next (more expensive) criterion for that candidate.
  4. Re-insert with updated score, or reject if the criterion fails.
  5. Repeat until the top candidate has been fully evaluated — this is the result.

Candidates that are clearly poor never reach the expensive criteria. In practice this eliminates the majority of full evaluations.

Asynchronous raycasting

Ray-cast conditions (line-of-sight checks) are expensive. Rather than blocking, each query submits ray-casts to an async batch queue:

  1. Candidates pass the cheap synchronous conditions first.
  2. Surviving candidates are batched into an async ray-cast job.
  3. On completion, results filter the candidate list; weights are applied to survivors.
  4. The agent acts on the result one or two frames later.

This amortises ray-cast cost across frames and avoids mid-frame stalls. In Unity, Physics.RaycastCommand with the Jobs System supports this pattern directly.

// Unity Jobs-based async raycast batch (illustrative)
var commands = new NativeArray<RaycastCommand>(candidates.Count, Allocator.TempJob);
var results  = new NativeArray<RaycastHit>(candidates.Count, Allocator.TempJob);
 
for (int i = 0; i < candidates.Count; i++)
    commands[i] = new RaycastCommand(npcEyePos, candidates[i] - npcEyePos, maxRange);
 
JobHandle handle = RaycastCommand.ScheduleBatch(commands, results, 32);
handle.Complete();
 
// Filter candidates using results
for (int i = 0; i < results.Length; i++)
    if (results[i].collider == null) // no hit = clear line of sight
        validCandidates.Add(candidates[i]);
 
commands.Dispose();
results.Dispose();

Squad-level position selection

Individual position selection optimises for a single agent. Squad-level selection adds constraints between agents:

  • Spread: No two squad members should select positions within a minimum separation distance.
  • Mutual coverage: Squad positions should collectively cover multiple enemy approach vectors.
  • Flank coverage: At least one member should have a position that flanks relative to the others.

One approach is a centralised squad query: generate a set of position combinations for all squad members simultaneously, score the combination as a whole, and assign positions. This is expensive but optimal.

A cheaper approach is sequential selection with exclusion zones: members select positions in order, adding exclusion zones around their chosen position before the next member queries. This approximates spread and de-conflicts positions without a combinatorial search.

The centralised vs. decentralised trade-off is discussed explicitly by Jack (Chapter 1): centralised selection produces better tactical patterns but tightly couples squad members. Decentralised (per-agent queries with soft exclusion signals) scales better and tolerates agent failure.


Evidence

  • Jack (Game AI Pro 360, Chapter 1, see source-game-ai-pro-360-tactics-strategy) describes the full TPS query language as implemented in Crysis 2 using CryEngine’s Lua-based AI scripting system.
  • Johnson (Chapter 14) extends the architecture with the cover segment / CoverMap representation and the tactical pathfinding cover-bias approach.
  • The three-phase pipeline (generate/filter/weight) is a recurring architectural pattern, applicable beyond shooter AI to any spatial optimisation problem.

Implications

  • TPS is not a specialised AI technique — it is a general spatial query system. The same generation/filter/weight pipeline can answer “where should a turret be placed?”, “which enemy should I attack first?”, or “where should I retreat if my health drops?“.
  • Separating query authoring from execution (via a DSL) is a strong scaling strategy: designers iterate on tactics without code changes; engineers maintain the query engine.
  • Cover-biased pathfinding eliminates a common two-step anti-pattern (“go to cover, then move”) and makes NPC navigation feel more organic.

Open questions

  • The centralised vs. decentralised squad selection trade-off is acknowledged but unresolved — is there a middle-ground with minimal coupling (e.g. only sharing exclusion zones rather than full query coordination)?
  • How do cover segments generalise to destructible environments where cover geometry changes at runtime?
  • What is the practical frame-budget overhead of async ray-cast batching in Unity at 50+ concurrent NPCs?

influence-maps · squad-ai-patterns · npc-perception-systems · combat-coordinator-pattern · game-ai-agent-design · strategic-ai-rts · source-game-ai-pro-360-tactics-strategy