AI director

Distance pays, challenge rating brakes

There is no single pool of director tokens. Spawn timers are mostly paid for by the metres your lead player walks, and the threat already chasing you decides what is allowed to join.

People often describe the director as spending “tokens”. The code has nothing like that. (The only token system in the game handles the Arbites dog’s pounce.) What it has instead is two separate mechanisms: one that fills the timers and one that holds them back.

The fuel: metres travelled

Specials, hordes and trickle hordes all run on countdown timers. With travel_distance_spawning switched on, as it is in every normal and Havoc template, the countdown goes down by one per metre the lead player moves forward, not one per second. Standing still does not refill anything. Moving forward is what pays for the next spawn.

Several things switch the specials timers over to real seconds, so they keep ticking even when nobody moves:

  • the aggroed challenge rating goes above 20 (at R5+);
  • a horde is active;
  • a monster is aggroed;
  • a scripted event is running.

In other words, a hot fight keeps the specials coming. Hordes also need a per-mission distance quota before the next one is allowed (40-70 m per horde at R5).

From Havoc 16 the horde countdown stops caring about distance: the Havoc horde templates set travel_distance_spawning = false, so the clock runs whether you push or hold. The distance quota still applies, though. Each horde needs 45-70 m of new forward progress before it may spawn (next chapter).

Havoc 16+ horde timers tick by timemanagers/pacing/horde_pacing/templates/mutator_horde_pacing_template.lua:216
    havoc_01 = {
      aggro_nearby_roamers_zone_range = 2,
      max_active_minions = 90,
      max_active_minions_for_ambush = 50,
      num_trickle_hordes_active_for_cooldown = 3,
      time_between_waves = 10,
      travel_distance_spawning = false,
      trigger_heard_dialogue = true,
      horde_timer_range = {
        140,
        240,
      },
      first_spawn_timer_modifer = {
Open managers/pacing/horde_pacing/templates/mutator_horde_pacing_template.lua at line 216 on GitHub

The brake: challenge rating

Every enemy breed has a challenge_rating. When an enemy notices you, its rating is added to a running total; when it dies or loses interest, the rating is removed.

Enemy Challenge rating
Poxwalker, newly infected 0.4
Melee or ranged cultist 0.75
Renegade infantry 1
Shotgunner, plasma gunner 3
Gunner, Rager, Mauler, radio operator 4
Hound, mutant 6
Ogryn (any) 8
Daemonhost 20
Plague ogryn, Beast of Nurgle, Chaos Spawn 30
Poxwalker challenge ratingsettings/breed/breeds/chaos/chaos_poxwalker_breed.lua:34
  challenge_rating = 0.4,
Open settings/breed/breeds/chaos/chaos_poxwalker_breed.lua at line 34 on GitHub
Aggroed enemies add their challenge ratingmanagers/pacing/pacing_manager.lua:863
PacingManager.add_aggroed_minion = function (self, unit)
  local aggroed_minions = self._aggroed_minions

  if not aggroed_minions[unit] then
    local challenge_rating = self:_get_challenge_rating(unit)

    if challenge_rating then
      self._total_challenge_rating = math.max(math.round_with_precision(self._total_challenge_rating + challenge_rating, 2), 0)
      self._saved_challenge_ratings[unit] = challenge_rating
    end

    self._num_aggroed_minions = self._num_aggroed_minions + 1
    aggroed_minions[unit] = true

    self._side_system:add_aggroed_minion(unit)

    if self._in_safe_zone then
      self:pause_spawn_type("specials", false)
      self:pause_spawn_type("hordes", false)
      self:pause_spawn_type("trickle_hordes", false)
Open managers/pacing/pacing_manager.lua at line 863 on GitHub

Before anything spawns, the director checks a gate. A spawn type is refused if:

  • more than 145 enemies are allocated;
  • the current pacing state does not allow it;
  • the aggroed total is above that type’s threshold.
The global spawn gatemanagers/pacing/pacing_manager.lua:474
local HARD_ALLOCATED_LIMIT = 145

PacingManager.spawn_type_enabled = function (self, spawn_type)
  if self._disabled and spawn_type ~= "terror_events" then
    return false, "pacing_is_disabled"
  end

  local total_allocated_num_enemies = Managers.state.minion_spawn:total_allocated_num_enemies()

  if total_allocated_num_enemies > HARD_ALLOCATED_LIMIT then
    return false, "Hard_allocated_limit_reached"
  end

  local challenge_rating_thresholds = self._challenge_rating_thresholds
  local challenge_rating_threshold = challenge_rating_thresholds[spawn_type]
  local total_challenge_rating = self._total_challenge_rating

  if challenge_rating_threshold and challenge_rating_threshold < total_challenge_rating then
    return false, "disabled_by_challenge_rating"
  end

  if self._paused_spawn_types[spawn_type] then
    return false, "paused"
  end

  if not self._allowed_spawn_types or not self._allowed_spawn_types[spawn_type] and not self._heat_pacing:active() then
    return false, "not_allowed"
  end

  if self._heat_pacing:active() then
    local current_heat_stage_settings = self._heat_pacing:current_stage_settings()
Open managers/pacing/pacing_manager.lua at line 474 on GitHub

Havoc raises two thresholds. At C5, specials stop at 160 aggroed rating instead of 80, and hordes stop at 180 instead of 60. Trickles (40), patrols (180) and scripted events (200) keep the normal values.

Havoc challenge-rating thresholdsmanagers/pacing/templates/havoc_pacing_template.lua:27
pacing_template.challenge_rating_thresholds = {
  specials = _challenge_rating_multiplier_steps(80),
  hordes = _challenge_rating_multiplier_steps(90),
  trickle_hordes = _challenge_rating_multiplier_steps(20),
  roamers = _challenge_rating_multiplier_steps(90),
  terror_events = _challenge_rating_multiplier_steps(100),
}
Open managers/pacing/templates/havoc_pacing_template.lua at line 27 on GitHub

In Havoc the brake barely engages. A monster, eight ogryns and twenty elites aggroed at once add up to 174, which is still under the 180 horde threshold. Specials stop at 160, so they keep spawning through almost anything. Clearing aggroed elites fast is what keeps the specials timers on distance instead of seconds.