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:
- Local force ratio — self-contained scan, stored on
bot_t, gated at bot_tactical ≥ 1
- Tactical stance — reads force ratio + health + ammo, stored on
bot_t, gated at bot_tactical ≥ 1
- Movement speed adaptation — reads stance, modifies movement commands, gated at
bot_tactical ≥ 2
- Engagement decision filter — reads stance, gates
self->enemy assignment, gated at bot_tactical ≥ 3
- Weapon matchup assessment — reads enemy weapon + range + own state, gated at
bot_tactical ≥ 4
- Dropped weapon opportunism — reads matchup + item table, gated at
bot_tactical ≥ 5
- 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.c — BOTLIB_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.h — bot_force_ratio_t, bot_stance_t enums; BOTLIB_ShouldEngage() declaration
src/action/g_local.h — bot.force_ratio, bot.stance fields on bot_t
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_tacticalcvar (0–5, default0) controls how much of the system is active. This is intentionally separate frombot_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_tacticalScaleEach level unlocks a distinct, visible change in bot behaviour. No two levels are the same.
0123451. Local Force Ratio Assessment
(required by: all levels ≥ 1)
Add a per-bot
bot_force_ratio_tenum, 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.Assessment inputs:
BOTLIB_CheckBotRules()bot.force_ratioonbot_tAt
bot_tactical 1, this runs and setsbot.stancebut no downstream behaviour reads it yet. The value is available for logging/debugging.2. Tactical Stance
(required by: all levels ≥ 1)
A
bot_stance_tderived from the force ratio and secondarily from health and ammo state. All downstream behaviour reads from this — not from the raw force ratio.Transition rules (evaluated once per second):
FORCE_DOMINANTAGGRESSIVEFORCE_ADVANTAGEAGGRESSIVEorBALANCED(weighted random)FORCE_EVENBALANCEDFORCE_OUTNUMBEREDCAUTIOUSFORCE_CRITICALSTEALTHRETREATRETREATRETREAToverrides any force ratio. Stored per-bot asbot.stanceonbot_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_tto movement speed during navigation (between engagements and while seeking cover).SPEED_WALKandSPEED_RUNalready exist — stance selects which to apply.AGGRESSIVEBALANCEDCAUTIOUSSTEALTHRETREATIn
STEALTHstance, 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 beforeself->enemyis committed inBOTLIB_FindEnemy(). Returnsfalseto suppress the engagement entirely — the bot sees the enemy but chooses not to commit.Filter logic:
STEALTH+ target not isolatedSTEALTH+ target isolatedCAUTIOUS+ outnumbered in areaRETREAT+ any enemyEVENor better"Isolated" = no other enemy within ~400 units of the target.
When suppressed, the bot clears
self->enemyand 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:
When the matchup is losing:
RETREATstance and break contact6. 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:
Behaviour:
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_teamusing the cooldown infrastructure from issue #293 (per-bot and team-wide cooldowns, 6+ variants per trigger, randomly selected).Proposed message triggers:
AGGRESSIVE(team has local dominance)STEALTH(bot heavily outnumbered)RETREATDOMINANTteam-wideImplementation Order
Dependencies flow top-to-bottom — each item requires the one above it:
bot_t, gated atbot_tactical ≥ 1bot_t, gated atbot_tactical ≥ 1bot_tactical ≥ 2self->enemyassignment, gated atbot_tactical ≥ 3bot_tactical ≥ 4bot_tactical ≥ 5bot_tactical ≥ 5Affected Files
src/action/botlib/botlib_ai.c— stance update block, enemy chase suppression for carrier/retreatsrc/action/acesrc/acebot_ai.c—BOTLIB_FindEnemy():BOTLIB_ShouldEngage()hooksrc/action/acesrc/acebot_movement.c— movement speed selection by stancesrc/action/botlib/botlib_weapons.c— weapon matchup assessment, disengage decisionssrc/action/botlib/botlib_items.c— dropped weapon opportunism, pickup priority adjustmentsrc/action/botlib/botlib_communication.c— stance-change chat triggerssrc/action/botlib/botlib_spawn.c— force ratio + stance update in per-second block; registerbot_tacticalcvarsrc/action/botlib/botlib.h—bot_force_ratio_t,bot_stance_tenums;BOTLIB_ShouldEngage()declarationsrc/action/g_local.h—bot.force_ratio,bot.stancefields onbot_t