Skip to content

[botlib] Tactical situational awareness: force ratio, stance system, engagement filtering, and weapon matchup #296

@darkshade9

Description

@darkshade9

Overview

Bots currently treat every engagement identically — they see an enemy, they fight. There is no assessment of whether the fight is winnable, whether the odds favour aggression or retreat, or whether a different weapon, position, or pace of movement would produce a better outcome. This leads to unrealistic and often self-destructive behaviour: a lone bot rushing three enemies head-on, a bot with one magazine left picking a prolonged firefight, or a bot with an M4 trying to out-duel a sniper at long range.

This issue adds a tactical situational awareness layer that sits beneath all combat decisions. It is game-mode agnostic (DM, teamplay, CTF all benefit) and provides the foundation that weapon coordination, team composition, and advanced combat AI can build on.

A new bot_tactical cvar (0–5, default 0) controls how much of the system is active. This is intentionally separate from bot_skill, which governs aim and reaction time. The two axes are independent — a server can run high-skill bots that play simply, low-skill bots that play cleverly, or any combination.

Implementation order matters — each item builds on the one before it.


bot_tactical Scale

Each level unlocks a distinct, visible change in bot behaviour. No two levels are the same.

Value Behaviour
0 Off. Current behaviour fully preserved. Bots engage every enemy they see without assessment.
1 Force ratio assessed, tactical stance computed. Bots know if they are outnumbered but do not yet change what they do. Internal foundation for all higher levels; useful for validating the assessment logic before any behaviour changes.
2 Movement adaptation active. Outnumbered bots walk instead of run, hug walls, avoid crossing open ground. First visible behaviour change.
3 Engagement filter active. Bots refuse bad fights — will not charge a group when alone, disengage when isolated and outmatched.
4 Weapon matchup assessment. Bots reposition based on range vs weapon type, recognise when the enemy loadout has the advantage, seek better angles rather than trading shots from a losing position.
5 Full system. Dropped weapon opportunism, complete stealth behaviour when isolated, team pressure chat on stance transitions.

1. Local Force Ratio Assessment

(required by: all levels ≥ 1)

Add a per-bot bot_force_ratio_t enum, updated once per second (not per-frame) by scanning living players within a ~1500-unit local radius. This is deliberately local — a team of 4v4 means nothing if three of your teammates are on the other side of the map.

typedef enum {
    FORCE_CRITICAL,     // Heavily outnumbered locally (1v3+)
    FORCE_OUTNUMBERED,  // Slightly outnumbered (1v2, 2v3)
    FORCE_EVEN,         // Roughly equal forces in area
    FORCE_ADVANTAGE,    // Slight local superiority (2v1, 3v2)
    FORCE_DOMINANT,     // Heavy local superiority (3v1+)
} bot_force_ratio_t;

Assessment inputs:

  • Living enemies within 1500 units
  • Living allies within 1500 units (including self)
  • Ratio bucketed into the enum above
  • Updated via the existing per-second throttle block in BOTLIB_CheckBotRules()
  • Stored per-bot as bot.force_ratio on bot_t

At bot_tactical 1, this runs and sets bot.stance but no downstream behaviour reads it yet. The value is available for logging/debugging.


2. Tactical Stance

(required by: all levels ≥ 1)

A bot_stance_t derived from the force ratio and secondarily from health and ammo state. All downstream behaviour reads from this — not from the raw force ratio.

typedef enum {
    STANCE_AGGRESSIVE,  // Dominant — press hard, apply pressure
    STANCE_BALANCED,    // Even — standard engagement behaviour
    STANCE_CAUTIOUS,    // Slight disadvantage — pick targets, don't overextend
    STANCE_STEALTH,     // Heavy disadvantage — walk, avoid contact, isolate targets
    STANCE_RETREAT,     // Critical — break contact, find cover, heal or rearm
} bot_stance_t;

Transition rules (evaluated once per second):

Condition Stance
FORCE_DOMINANT AGGRESSIVE
FORCE_ADVANTAGE AGGRESSIVE or BALANCED (weighted random)
FORCE_EVEN BALANCED
FORCE_OUTNUMBERED CAUTIOUS
FORCE_CRITICAL STEALTH
Any ratio + health < 20 + no bandage available RETREAT
Any ratio + primary ammo critically low (< 1 clip, no viable secondary) RETREAT

RETREAT overrides any force ratio. Stored per-bot as bot.stance on bot_t.

At bot_tactical 1, stance is computed and stored but nothing acts on it yet.


3. Movement Speed Adaptation

(active at: bot_tactical ≥ 2)

Maps bot_stance_t to movement speed during navigation (between engagements and while seeking cover). SPEED_WALK and SPEED_RUN already exist — stance selects which to apply.

Stance Movement Notes
AGGRESSIVE Run Press forward fast
BALANCED Run Normal behaviour
CAUTIOUS Walk or run Randomised; prefer walking in open areas
STEALTH Walk Crouch through sightlines, hug walls, avoid open ground
RETREAT Run (away) Sprint toward nearest safe node or cover

In STEALTH stance, the bot does not fire first unless a target is confirmed isolated (no other enemy within ~400 units, or target is facing away).

This is the first change visible to a human watching the bots. A lone bot against three enemies will visibly slow down and start moving along walls rather than sprinting across open ground. It should be noted that slowing down (SPEED_WALK) should only apply during the STEALTH stance -- SPEED_WALK significantly reduces the amount footsteps sound a player makes. That said, a bot using stealth slippers will never need to perform this change, their footsteps are already silenced and should run at a full speed. SPEED_WALK is purely for STEALTH movement; it assumes the enemy players have little to no clue where the walking bot is, otherwise the bot should SPEED_RUN at all times. Walking is dangerously slow and easy to attack.


4. Engagement Decision Filter

(active at: bot_tactical ≥ 3)

A static qboolean BOTLIB_ShouldEngage(edict_t *self, edict_t *enemy) check inserted before self->enemy is committed in BOTLIB_FindEnemy(). Returns false to suppress the engagement entirely — the bot sees the enemy but chooses not to commit.

Filter logic:

Scenario Decision
STEALTH + target not isolated Suppress — find a different route, do not reveal position
STEALTH + target isolated Engage (flanking approach preferred)
CAUTIOUS + outnumbered in area Engage only if target is isolated
RETREAT + any enemy Suppress — run, do not stop to fight
Any stance + ammo critically low Suppress — seek pickup or cover first
Good matchup + EVEN or better Engage normally

"Isolated" = no other enemy within ~400 units of the target.

When suppressed, the bot clears self->enemy and continues on its current path goal rather than diverting toward the threat.


5. Weapon Matchup Assessment

(active at: bot_tactical ≥ 4)

Before engaging (feeds into Item 4's filter) and during sustained combat, assess whether the bot's current weapon suits the range and the specific enemy loadout by reading enemy->client->weapon.

Effective range reference:

Weapon Ideal range (units) Losing matchup
HC < 300 Enemy keeps distance; any weapon at > 400
M3 < 400 Mid-range against M4/MP5
M4 300–900 Sniper at > 900; HC at < 200
MP5 200–700 HC up close; sniper at extreme range
Sniper > 700 Any weapon if enemy closes to < 400
MK23 < 500 Fallback only; avoid prolonged fights
Knife < 150 Melee commitment only
Kick/Punc < 50 Extreme close quarters

When the matchup is losing:

  • Disengage and seek a position that favours the bot's weapon (closer or farther depending on loadout)
  • If a dropped weapon of a more suitable type is within ~600 units and reachable without crossing direct fire, path toward it before re-engaging
  • If no viable option, transition to RETREAT stance and break contact

6. Dropped Weapon Opportunism

(active at: bot_tactical ≥ 5)

When the current engagement is unfavourable due to weapon mismatch and a better-suited dropped weapon is nearby, the bot temporarily disengages to pick it up rather than fighting with an inferior loadout.

Trigger conditions:

  • Matchup assessment (Item 5) rates the current weapon as losing for this engagement
  • A dropped weapon of a more suitable type is within ~600 units
  • The pickup is reachable without crossing direct enemy sightlines

Behaviour:

  • Suppress the current engagement
  • Path to the dropped weapon via the item table / nav system
  • Pick it up, then re-evaluate the engagement with the new loadout

Also applies to ammo: a bot nearly out of primary ammo that spots matching ammo pickups nearby should grab them before re-engaging rather than charging in with one clip left.


7. Team Pressure and Stealth Chat

(active at: bot_tactical ≥ 5)

Announces significant stance transitions via say_team using the cooldown infrastructure from issue #293 (per-bot and team-wide cooldowns, 6+ variants per trigger, randomly selected).

Proposed message triggers:

Event Example say_team message
Stance → AGGRESSIVE (team has local dominance) "We've got numbers, push up" / "Press them, they're falling back"
Stance → STEALTH (bot heavily outnumbered) "I'm outnumbered, going quiet" / "Playing it slow, don't blow my cover"
Stance → RETREAT "Breaking contact" / "I need to fall back, cover me"
Weapon mismatch disengage "Wrong weapon for this, repositioning" / "Need a better angle"
Bot picks up dropped weapon (silent — no announce)
Local force ratio flips to DOMINANT team-wide "They're cracking, keep the pressure on" / "Push now"

Implementation Order

Dependencies flow top-to-bottom — each item requires the one above it:

  1. Local force ratio — self-contained scan, stored on bot_t, gated at bot_tactical ≥ 1
  2. Tactical stance — reads force ratio + health + ammo, stored on bot_t, gated at bot_tactical ≥ 1
  3. Movement speed adaptation — reads stance, modifies movement commands, gated at bot_tactical ≥ 2
  4. Engagement decision filter — reads stance, gates self->enemy assignment, gated at bot_tactical ≥ 3
  5. Weapon matchup assessment — reads enemy weapon + range + own state, gated at bot_tactical ≥ 4
  6. Dropped weapon opportunism — reads matchup + item table, gated at bot_tactical ≥ 5
  7. Team pressure chat — hooks into stance transitions, gated at bot_tactical ≥ 5

Affected Files

  • src/action/botlib/botlib_ai.c — stance update block, enemy chase suppression for carrier/retreat
  • src/action/acesrc/acebot_ai.cBOTLIB_FindEnemy(): BOTLIB_ShouldEngage() hook
  • src/action/acesrc/acebot_movement.c — movement speed selection by stance
  • src/action/botlib/botlib_weapons.c — weapon matchup assessment, disengage decisions
  • src/action/botlib/botlib_items.c — dropped weapon opportunism, pickup priority adjustment
  • src/action/botlib/botlib_communication.c — stance-change chat triggers
  • src/action/botlib/botlib_spawn.c — force ratio + stance update in per-second block; register bot_tactical cvar
  • src/action/botlib/botlib.hbot_force_ratio_t, bot_stance_t enums; BOTLIB_ShouldEngage() declaration
  • src/action/g_local.hbot.force_ratio, bot.stance fields on bot_t

Metadata

Metadata

Assignees

No one assigned

    Labels

    aq2-tngOnly TNG (server dll) affectedenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions