Skip to content

Unit Squads and Group Behaviors

Recommended Reading: - Explore how to effectively curate Combat Maneuvers with individual units, providing insights into the workings of the combat maneuver and behavior system in ares-sc2. - Previous knowledge of the unit role system is beneficial

Why Opt for Unit Squads and Group Behaviors?

  • Enable a squadron of units to function cohesively as a group, rather than as individual units lacking consideration for the collective strength in the face of nearby enemies.
  • Gain visibility into the spatial distribution of our units; recognizing scenarios where our main squad may have fewer units than smaller squads moving across the map. This insight could prompt strategic decisions such as pulling back to allow units to converge.
  • Embrace the concept of group thinking within a squad, unlocking the potential for human-like control, where all units respond to a single, coordinated action.

Group Behavior tutorial

To begin, here's a basic example of a Zerg bot utilizing the unit role system to assign all units to the ATTACKING role.

from ares import AresBot
from ares.consts import ALL_STRUCTURES, WORKER_TYPES, UnitRole

from sc2.ids.unit_typeid import UnitTypeId
from sc2.unit import Unit
from sc2.units import Units

class ZergBot(AresBot):
    def __init__(self, game_step_override = None):
        super().__init__(game_step_override)

    async def on_step(self, iteration: int) -> None:
        # retreive all attacking units
        attackers: Units = self.mediator.get_units_from_role(
            role=UnitRole.ATTACKING
        )

    async def on_unit_created(self, unit: Unit) -> None:
        # When a unit is created, 
        # assign it to ATTACKING using ares unit role system
        await super(ZergBot, self).on_unit_created(unit)
        type_id: UnitTypeId = unit.type_id
        # don't assign structures or workers
        if type_id in ALL_STRUCTURES or type_id in WORKER_TYPES:
            return

        # assign all other units to ATTACKING role by default
        self.mediator.assign_role(tag=unit.tag, role=UnitRole.ATTACKING)

Now imagine at some specific moment we would like to separate any roaches into their own separate hit squad. In an actual game you should choose to do this depending on the game state, but for the purposes of clarity in this tutorial lets seperate the roaches at 6 minutes.

from ares import AresBot
from ares.consts import ALL_STRUCTURES, WORKER_TYPES, UnitRole

from sc2.ids.unit_typeid import UnitTypeId
from sc2.unit import Unit
from sc2.units import Units

class ZergBot(AresBot):
    def __init__(self, game_step_override = None):
        super().__init__(game_step_override)

        self._assigned_roach_hit_squad: bool = False

    async def on_step(self, iteration: int) -> None:
        await super(ZergBot, self).on_step(iteration)
        # retreive all attacking units
        attackers: Units = self.mediator.get_units_from_role(
            role=UnitRole.ATTACKING
        )

        # retreive the roach hit squad, if one has been assigned
        roach_hit_squad: Units = self.mediator.get_units_from_role(
            role=UnitRole.CONTROL_GROUP_ONE, unit_type=UnitTypeId.ROACH
        )

        # At 6 minutes assign all roaches to CONTROL_GROUP_ONE
        # This will remove them from ATTACKING automatically
        if not self._assigned_roach_hit_squad and self.time > 360.0:
            self._assigned_roach_hit_squad = True
            roaches: list[Unit] = [
                u for u in attackers if u.type_id == UnitTypeId.ROACH
            ]
            for roach in roaches:
                self.mediator.assign_role(
                    tag=roach.tag, role=UnitRole.CONTROL_GROUP_ONE
                )

    async def on_unit_created(self, unit: Unit) -> None:
        # When a unit is created, 
        # assign it to ATTACKING using ares unit role system
        await super(ZergBot, self).on_unit_created(unit)
        type_id: UnitTypeId = unit.type_id
        # don't assign structures or workers
        if type_id in ALL_STRUCTURES or type_id in WORKER_TYPES:
            return

        # assign all other units to ATTACKING role by default
        self.mediator.assign_role(tag=unit.tag, role=UnitRole.ATTACKING)

Notice we have now seperated all roaches from the main UnitRole.ATTACKING force and provided them with a new UnitRole.CONTROL_GROUP_ONE role. We can now control these forces separately. Now onto the fun part of curating group combat maneuvers!

We will add a new method to control our hit squad, let's name it control_roach_hit_squad()

from ares import AresBot
from ares.behaviors.combat import CombatManeuver
from ares.behaviors.combat.group import AMoveGroup
from ares.consts import ALL_STRUCTURES, WORKER_TYPES, UnitRole

from sc2.ids.unit_typeid import UnitTypeId
from sc2.position import Point2
from sc2.unit import Unit
from sc2.units import Units

class ZergBot(AresBot):
    def __init__(self, game_step_override = None):
        super().__init__(game_step_override)

        self._assigned_roach_hit_squad: bool = False

    async def on_step(self, iteration: int) -> None:
        await super(ZergBot, self).on_step(iteration)
        # retreive all attacking units
        attackers: Units = self.mediator.get_units_from_role(
            role=UnitRole.ATTACKING
        )

        # retreive the roach hit squad, if one has been assigned
        roach_hit_squad: Units = self.mediator.get_units_from_role(
            role=UnitRole.CONTROL_GROUP_ONE, unit_type=UnitTypeId.ROACH
        )

        self.control_roach_hit_squad(
            roach_hit_squad=roach_hit_squad, 
            target=self.enemy_start_locations[0]
        )

        # At 6 minutes assign all roaches to CONTROL_GROUP_ONE
        # This will remove them from ATTACKING automatically
        if not self._assigned_roach_hit_squad and self.time > 360.0:
            self._assigned_roach_hit_squad = True
            roaches: list[Unit] = [
                u for u in attackers if u.type_id == UnitTypeId.ROACH
            ]
            for roach in roaches:
                self.mediator.assign_role(
                    tag=roach.tag, role=UnitRole.CONTROL_GROUP_ONE
                )

    def control_roach_hit_squad(
        self, 
        roach_hit_squad: Units, 
        target: Point2
    ) -> None:
        # declare a new group maneuver
        roach_squad_maneuver: CombatManeuver = CombatManeuver()
        # add group behaviors, these can be behaviors provided by ares
        # or create your own custom group behaviors!
        roach_squad_maneuver.add(
          AMoveGroup(
            group=roach_hit_squad, 
            group_tags={r.tag for r in roach_hit_squad}, 
            target=target
          )
        )

        self.register_behavior(roach_squad_maneuver)

    async def on_unit_created(self, unit: Unit) -> None:
        # When a unit is created, 
        # assign it to ATTACKING using ares unit role system
        await super(ZergBot, self).on_unit_created(unit)
        type_id: UnitTypeId = unit.type_id
        # don't assign structures or workers
        if type_id in ALL_STRUCTURES or type_id in WORKER_TYPES:
            return

        # assign all other units to ATTACKING role by default
        self.mediator.assign_role(tag=unit.tag, role=UnitRole.ATTACKING)

This method creates a basic "a-move" group maneuver for our roaches. Given a target, the roaches will advance towards it. Moreover, ares-sc2 automatically checks for duplicated actions to prevent action spam, enabling your bot to "a-move" akin to a human player.

Extending the group maneuver

Similar to individual combat maneuvers, Behaviors should be orchestrated in priority order. ares executes behaviors according to the user-provided order, therefore if a behavior executes and action, any subsequent behaviors will be ignored. With this in mind, let's add a group behavior for when enemies are close. We'd like our roaches to be aggressive, closing in on nearby enemies using the StutterGroupForward behavior. Otherwise, if no enemies are present, the AMoveGroup behavior will execute:

from cython_extensions import cy_center

from sc2.position import Point2
from sc2.units import Units

from ares.behaviors.combat import CombatManeuver
from ares.behaviors.combat.group import AMoveGroup, StutterGroupForward
from ares.consts import UnitTreeQueryType


def control_roach_hit_squad(
        self, 
        roach_hit_squad: 
        Units, target: Point2
    ) -> None:
    squad_position: Point2 = Point2(cy_center(roach_hit_squad))

    # retreive close enemy to the roach squad
    close_ground_enemy: Units = self.mediator.get_units_in_range(
        start_points=[squad_position],
        distances=15.5,
        query_tree=UnitTreeQueryType.EnemyGround,
    )[0]

    # declare a new group maneuver
    roach_squad_maneuver: CombatManeuver = CombatManeuver()

    # stutter forward to any ground enemies
    # as this behavior is added first to the maneuver it 
    # has the highest priority
    roach_squad_maneuver.add(
      StutterGroupForward(
        group=roach_hit_squad,
        group_tags={u.tag for u in roach_hit_squad},
        group_position=squad_position,
        target=target,
        enemies=close_ground_enemy,
      )
    )

    # if StutterGroupForward does not execute, our units will AMove
    roach_squad_maneuver.add(
      AMoveGroup(
        group=roach_hit_squad, 
        group_tags={r.tag for r in roach_hit_squad}, 
        target=target
      )
    )

    self.register_behavior(roach_squad_maneuver)

Unit Squads: Enhanced Group Tactics

Our roach_squad_maneuver works well, but controlling roaches as a group has a significant flaw. Imagine our roach squad is scattered across the map; issuing StutterGroupForward to roaches that don't have nearby enemies is ineffective. Moreover, calculating close enemies to our group becomes inaccurate, leading to undesirable behavior.

Thankfully, we can utilize the UnitSquad system in ares-sc2 to split our hit squad into logical groups! Let's enhance the control_roach_hit_squad method to incorporate this:

from sc2.position import Point2
from sc2.unit import Unit
from sc2.units import Units

from ares.behaviors.combat import CombatManeuver
from ares.behaviors.combat.group import AMoveGroup, StutterGroupForward
from ares.consts import UnitRole, UnitTreeQueryType
from ares.managers.squad_manager import UnitSquad


def control_roach_hit_squad(
        self, 
        roach_hit_squad: Units, 
        target: Point2
    ) -> None:

    squads: list[UnitSquad] = self.mediator.get_squads(
        role=UnitRole.CONTROL_GROUP_ONE, squad_radius=9.0
    )
    for squad in squads:
        squad_position: Point2 = squad.squad_position
        units: list[Unit] = squad.squad_units
        squad_tags: set[int] = squad.tags

        # retreive close enemy to the roach squad
        close_ground_enemy: Units = self.mediator.get_units_in_range(
            start_points=[squad_position],
            distances=11.5,
            query_tree=UnitTreeQueryType.EnemyGround,
        )[0]

        # declare a new group maneuver
        roach_squad_maneuver: CombatManeuver = CombatManeuver()

        # stutter forward to any ground enemies
        # as this behavior is added first to the maneuver it 
        # has the highest priority
        roach_squad_maneuver.add(
          StutterGroupForward(
            group=units,
            group_tags=squad_tags,
            group_position=squad_position,
            target=target,
            enemies=close_ground_enemy,
          )
        )

        # if StutterGroupForward does not execute, our units will AMove
        roach_squad_maneuver.add(
          AMoveGroup(
            group=units, 
            group_tags=squad_tags, 
            target=target
          )
        )

        self.register_behavior(roach_squad_maneuver)

Now, UnitSquad provides attributes such as squad_position, squad_units, and tags, eliminating the need for manual calculations. This allows for greater synergy with the group maneuver system, as we can pass this attributes directly as arguments into group behaviors:

squad: UnitSquad
AMoveGroup(
    group=squad.squad_units, 
    group_tags=squad.tags, 
    target=target
)

Our final bot looks a bit like this:

from ares import AresBot
from ares.behaviors.combat import CombatManeuver
from ares.behaviors.combat.group import AMoveGroup, StutterGroupForward
from ares.consts import ALL_STRUCTURES, WORKER_TYPES, UnitRole, UnitTreeQueryType
from ares.managers.squad_manager import UnitSquad

from sc2.ids.unit_typeid import UnitTypeId
from sc2.position import Point2
from sc2.unit import Unit
from sc2.units import Units

class ZergBot(AresBot):
    def __init__(self, game_step_override = None):
        super().__init__(game_step_override)

        self._assigned_roach_hit_squad: bool = False

    async def on_step(self, iteration: int) -> None:
        await super(ZergBot, self).on_step(iteration)
        # retreive all attacking units
        attackers: Units = self.mediator.get_units_from_role(
            role=UnitRole.ATTACKING
        )

        # retreive the roach hit squad, if one has been assigned
        roach_hit_squad: Units = self.mediator.get_units_from_role(
            role=UnitRole.CONTROL_GROUP_ONE, unit_type=UnitTypeId.ROACH
        )

        self.control_roach_hit_squad(
            roach_hit_squad=roach_hit_squad, 
            target=self.enemy_start_locations[0]
        )

        # At 6 minutes assign all roaches to CONTROL_GROUP_ONE
        # This will remove them from ATTACKING automatically
        if not self._assigned_roach_hit_squad and self.time > 360.0:
            self._assigned_roach_hit_squad = True
            roaches: list[Unit] = [
                u for u in attackers if u.type_id == UnitTypeId.ROACH
            ]
            for roach in roaches:
                self.mediator.assign_role(
                    tag=roach.tag, role=UnitRole.CONTROL_GROUP_ONE
                )

    def control_roach_hit_squad(
            self, 
            roach_hit_squad: Units, 
            target: Point2
        ) -> None:

        squads: list[UnitSquad] = self.mediator.get_squads(
            role=UnitRole.CONTROL_GROUP_ONE, squad_radius=9.0
        )
        for squad in squads:
            squad_position: Point2 = squad.squad_position
            units: list[Unit] = squad.squad_units
            squad_tags: set[int] = squad.tags

            # retreive close enemy to the roach squad
            close_ground_enemy: Units = self.mediator.get_units_in_range(
                start_points=[squad_position],
                distances=11.5,
                query_tree=UnitTreeQueryType.EnemyGround,
            )[0]

            # declare a new group maneuver
            roach_squad_maneuver: CombatManeuver = CombatManeuver()

            # stutter forward to any ground enemies
            # as this behavior is added first to the maneuver it 
            # has the highest priority
            roach_squad_maneuver.add(
              StutterGroupForward(
                group=units,
                group_tags=squad_tags,
                group_position=squad_position,
                target=target,
                enemies=close_ground_enemy,
              )
            )

            # if StutterGroupForward does not execute, our units will AMove
            roach_squad_maneuver.add(
              AMoveGroup(
                group=units, 
                group_tags=squad_tags, 
                target=target
              )
            )

            self.register_behavior(roach_squad_maneuver)

    async def on_unit_created(self, unit: Unit) -> None:
        # When a unit is created, 
        # assign it to ATTACKING using ares unit role system
        await super(ZergBot, self).on_unit_created(unit)
        type_id: UnitTypeId = unit.type_id
        # don't assign structures or workers
        if type_id in ALL_STRUCTURES or type_id in WORKER_TYPES:
            return

        # assign all other units to ATTACKING role by default
        self.mediator.assign_role(tag=unit.tag, role=UnitRole.ATTACKING)