AI director

Specials, slots and strikes

Each special lives in a slot with its own timer. Havoc adds slots, picks more disablers, and turns coordinated strikes from a rare event into a coin flip.

Slots

The director creates one slot per special allowed alive. Each slot gets a breed picked in advance and its own timer, and the slot timers are pushed a few metres apart so they do not all fire together. When a slot’s timer runs out and fewer specials are alive than the cap, the special spawns already aggroed, out of sight ahead of or behind the team.

Number of special slotsmanagers/pacing/specials_pacing/specials_pacing.lua:214
  local max_alive_specials = math.ceil(template.max_alive_specials * self._max_alive_specials_multiplier + self._max_alive_specials_bonus)
  local specials_slots = Script.new_array(max_alive_specials)
Open managers/pacing/specials_pacing/specials_pacing.lua at line 214 on GitHub

The base cap comes from resistance, and Havoc’s more_alive_specials modifier adds 2 to 6 on top:

Rank Base cap Havoc bonus Alive at once Timer per slot
10 5 +2 7 180-400
20 6 +3 9 120-320
30 8 +5 13 80-240
40 10 +6 16 60-220
Havoc specials at resistance 5managers/pacing/specials_pacing/templates/havoc_specials_pacing_template.lua:308
      max_alive_specials = 8,
      max_spawn_group_offset_range = 6,
      move_timer_when_challenge_rating_above = 20,
      move_timer_when_challenge_rating_above_delay = 20,
      move_timer_when_horde_active = true,
      move_timer_when_monster_active = true,
      move_timer_when_terror_event_active = true,
      num_required_speed_running_checks = 2,
      rushing_distance = 40,
      spawn_failed_wait_time = 5,
      speed_running_check_frequency = 5,
      speed_running_required_challenge_rating = 8,
      speed_running_required_distance = 10,
      travel_distance_spawning = true,
      timer_range = {
        80,
        240,
      },
      always_update_breeds = ALWAYS_UPDATE_AT_CHALLANGE_RATING_BREEDS,
      max_of_same = HIGH_MAX_OF_SAME,
      min_distances_from_target = DEFAULT_MIN_DISTANCES_FROM_TARGET,
      breeds = DEFAULT_BREEDS,
      disabler_target_alone_player_chance = DEFAULT_DISABLER_TARGET_ALONE_PLAYER_CHANCE,
      num_allowed_disablers_per_alive_targets = DEFAULT_NUM_ALLOWED_DISABLED_PER_ALIVE_TARGETS,
      disabler_override_duration = DEFAULT_DISABLER_OVERRIDE_DURATION,
      foreshadow_stingers = DEFAULT_FORESHADOW_STINGERS,
      foreshadow_stinger_timers = DEFAULT_FORESHADOW_STINGER_TIMERS,
      optional_prefered_spawn_direction = DEFAULT_OPTIONAL_PREFERED_SPAWN_DIRECTION,
      optional_mainpath_offset = DEFAULT_OPTIONAL_MAINPATH_OFFSET,
      spawn_stingers = DEFAULT_SPAWN_STINGERS,
Open managers/pacing/specials_pacing/templates/havoc_specials_pacing_template.lua at line 308 on GitHub
Havoc: extra alive specialssettings/havoc_settings.lua:123
    more_alive_specials = {
      {
        add_max_alive_specials = 2,
      },
      {
        add_max_alive_specials = 3,
      },
      {
        add_max_alive_specials = 4,
      },
      {
        add_max_alive_specials = 5,
      },
      {
        add_max_alive_specials = 6,
      },
Open settings/havoc_settings.lua at line 123 on GitHub

There is also a cap on the same breed alive at once. From R4 it is 2 hounds, 2 trappers, 4 mutants, 3 bombers, 3 grenadiers, 3 snipers and 2 flamers.

Max of the same special (R4+)managers/pacing/specials_pacing/templates/special_pacing_template_settings.lua:177
  high_max_of_same = {
    chaos_beast_of_nurgle = 1,
    chaos_hound = 2,
    chaos_plague_ogryn = 2,
    chaos_poxwalker_bomber = 3,
    chaos_spawn = 2,
    cultist_flamer = 2,
    cultist_grenadier = 2,
    cultist_mutant = 4,
    flamer = 2,
Open managers/pacing/specials_pacing/templates/special_pacing_template_settings.lua at line 177 on GitHub

Disablers per standing player

Hounds, trappers and mutants are disablers. Normally the number allowed alive depends on how many players are still standing, so a lone survivor faces at most one. Havoc replaces that table: at C5 the cap is 4 for any number of standing players, so the clutch player can face four disablers alone.

Havoc disabler cap per standing playermanagers/pacing/specials_pacing/templates/special_pacing_template_settings.lua:219
  default_num_allowed_disabled_per_alive_targets = {
    {
      0,
      0,
      1,
      1,
    },
    {
      0,
      1,
      1,
      2,
    },
    {
      2,
      2,
      3,
      3,
    },
    {
      3,
      3,
      3,
      3,
    },
    {
      4,
      4,
      4,
      4,
    },
  },
Open managers/pacing/specials_pacing/templates/special_pacing_template_settings.lua at line 219 on GitHub

Coordinated strikes and surges

When a special dies and no specials are left alive, the director rolls for a coordinated strike. On success, several slots get nearly identical timers, so 7-10 specials (at Havoc R5) arrive within a few seconds of each other. The strike can also start a surge: for 40-80 seconds every special that dies is replaced 5-13 seconds later.

Strike chance Surge chance
Auric (R5) 30% 20%
Havoc 30 (R5) 50% 40%
Havoc 31+ (R6) 60% 45%
Coordinated special strikesmanagers/pacing/specials_pacing/specials_pacing.lua:1199
SpecialsPacing._check_and_activate_coordinated_strike = function (self, template, current_special_slot)
  local specials_slots = self._specials_slots
  local t = Managers.time:time("gameplay")

  if not self._surges_paused and self._coordinated_surge_duration and t <= self._coordinated_surge_duration then
    local optional_coordinated_strike = true
    local coordinated_surge_timer_range = template.coordinated_surge_timer_range
    local min_timer_range, max_timer_range = coordinated_surge_timer_range[1], coordinated_surge_timer_range[2]
    local coordinated_surge_timer = math.random_range(min_timer_range, max_timer_range)

    self:_setup_specials_slot(specials_slots, current_special_slot, template, self._timer_modifier, nil, coordinated_surge_timer, nil, optional_coordinated_strike)

    return true
  end

  if self._num_spawned_specials > 0 then
    return false
  end

  for i = 1, self._max_alive_specials do
    local specials_slot = specials_slots[i]

    if specials_slot.coordinated_strike then
      return false
Open managers/pacing/specials_pacing/specials_pacing.lua at line 1199 on GitHub

Killing the last special alive is the trigger. On rank 30+ it is a coin flip whether that kill starts a wave of seven to ten more. Before you snipe the last trapper, make sure the team is together, topped up and not mid-horde.

Sounds that warn you

Hounds, trappers and mutants play a stinger a few seconds or metres before they spawn: 5 for hounds and mutants, 4 for trappers. The same sound means the special is about to appear, not that it is already on someone.

Foreshadow stinger lead timesmanagers/pacing/specials_pacing/templates/special_pacing_template_settings.lua:10
special_pacing_template_settings.default_foreshadow_stinger_timers = {
  chaos_hound = 5,
  cultist_mutant = 5,
  renegade_netgunner = 4,
}
Open managers/pacing/specials_pacing/templates/special_pacing_template_settings.lua at line 10 on GitHub

Punishments for splitting up

Three extra checks inject specials outside the normal timers:

  • Rush prevention. If the front player or the last player is more than rushing_distance from the next player (40 m at R5, 35 m at R6), a hound or mutant is sent at them.
  • Loner prevention (R4+). A player out of coherency for 6-10 seconds (at Havoc R5) gets a hound, mutant or trapper. This only fires while at least three players are standing.
  • Speed-run prevention. If the lead player keeps gaining 10+ metres every 5-second check, a disabler is placed ahead of them even past the alive cap.
Rush preventionmanagers/pacing/specials_pacing/specials_pacing.lua:1301
SpecialsPacing._update_rush_prevention = function (self, target_side_id, template, t)
  local rush_prevention_breeds = template.rush_prevention_breeds

  if not rush_prevention_breeds or t < self._rush_prevention_cooldown then
    return
  end

  if self._num_spawned_specials >= self._effective_max_alive_specials then
    return
  end

  local ahead_unit, ahead_travel_distance = Managers.state.main_path:ahead_unit(target_side_id)

  if not ahead_unit then
    return
  end

  local behind_unit, behind_travel_distance = Managers.state.main_path:behind_unit(target_side_id)
  local nav_world = self._nav_world
  local side_system = Managers.state.extension:system("side_system")
  local side = side_system:get_side(target_side_id)
  local target_units = side.valid_player_units
  local num_target_units = #target_units
  local second_ahead_distance, second_behind_distance = 0, math.huge

  if num_target_units == 1 then
    if not self._extra_scrambler_cooldown or t > self._extra_scrambler_cooldown then
      local player_unit = target_units[1]
      local unit_data_extension = ScriptUnit.extension(player_unit, "unit_data_system")
      local disabled_character_state_component = unit_data_extension:read_component("disabled_character_state")
Open managers/pacing/specials_pacing/specials_pacing.lua at line 1301 on GitHub
Loner preventionmanagers/pacing/specials_pacing/specials_pacing.lua:1401
SpecialsPacing._update_loner_prevention = function (self, target_side_id, template, t)
  local loner_prevention_breeds = template.loner_prevention_breeds

  if not loner_prevention_breeds or t < self._loner_prevention_cooldown then
    return
  end

  if self._num_spawned_specials >= self._effective_max_alive_specials then
    return
  end

  local side_system = Managers.state.extension:system("side_system")
  local side = side_system:get_side(target_side_id)
  local target_units = side.valid_player_units
  local num_target_units = #target_units
  local num_non_disabled_players = 0

  table.clear(ALONE_TARGETS)

  for i = 1, num_target_units do
    local player_unit = target_units[i]
    local unit_data_extension = ScriptUnit.extension(player_unit, "unit_data_system")
    local character_state_component = unit_data_extension:read_component("character_state")
    local requires_help = PlayerUnitStatus.requires_help(character_state_component)

    if not requires_help then
      num_non_disabled_players = num_non_disabled_players + 1
    end

    local coherency_extension = ScriptUnit.has_extension(player_unit, "coherency_system")
    local num_units_in_coherency = coherency_extension and coherency_extension:num_units_in_coherency()

    if num_units_in_coherency == 1 then
      ALONE_TARGETS[#ALONE_TARGETS + 1] = player_unit
Open managers/pacing/specials_pacing/specials_pacing.lua at line 1401 on GitHub

The director actively hunts the player who is alone. Staying in coherency is not only about toughness regen. It is the difference between the normal timers and an extra disabler aimed straight at you.