Building Starcraft 2 AI in Python with Python-sc2

Defending ourselves




Welcome to part 2 of the Starcraft 2 tutorials. Where we left off, we had established some of the basics of the game, as well as writing and running our own basic script to start. The code so far:

from sc2.bot_ai import BotAI  # parent class we inherit from
from sc2.data import Difficulty, Race  # difficulty for bots, race for the 1 of 3 races
from sc2.main import run_game  # function that facilitates actually running the agents in games
from sc2.player import Bot, Computer  #wrapper for whether or not the agent is one of your bots, or a "computer" player
from sc2 import maps  # maps method for loading maps to play in.


class IncrediBot(BotAI): # inhereits from BotAI (part of BurnySC2)
    async def on_step(self, iteration: int): # on_step is a method that is called every step of the game.
        print(f"This is my bot in iteration {iteration}") # prints out the iteration number (ie: the step).


run_game(  # run_game is a function that runs the game.
    maps.get("2000AtmospheresAIE"), # the map we are playing on
    [Bot(Race.Protoss, IncrediBot()), # runs our coded bot, protoss race, and we pass our bot object 
     Computer(Race.Zerg, Difficulty.Hard)], # runs a pre-made computer agent, zerg race, with a hard difficulty.
    realtime=False, # When set to True, the agent is limited in how long each step can take to process.
)

To continue, I'll start by modifying the print statement a bit to contain information on our buildings and units:

    async def on_step(self, iteration: int):
        #print(f"This is my bot in iteration {iteration}, workers: {self.workers}, idle workers: {self.workers.idle}, supply: {self.supply_used}/{self.supply_cap}")
        print(f"{iteration}, n_workers: {self.workers.amount}, n_idle_workers: {self.workers.idle.amount},", \
            f"minerals: {self.minerals}, gas: {self.vespene}, cannons: {self.structures(UnitTypeId.PHOTONCANNON).amount},", \
            f"pylons: {self.structures(UnitTypeId.PYLON).amount}, nexus: {self.structures(UnitTypeId.NEXUS).amount}", \
            f"gateways: {self.structures(UnitTypeId.GATEWAY).amount}, cybernetics cores: {self.structures(UnitTypeId.CYBERNETICSCORE).amount}", \
            f"stargates: {self.structures(UnitTypeId.STARGATE).amount}, voidrays: {self.units(UnitTypeId.VOIDRAY).amount}, supply: {self.supply_used}/{self.supply_cap}")
    

Here, we're just outputting the numbers of various buildings, units, and supply information. The idea here is mainly just for debugging purposes. If we add logic that we think will build a Stargate, but one isn't built, then the next question we'd have is if a Cybernetics core exists and if we had the supplies necessary to build the Stargate.

We're also using UnitTypeId here, so we'll need to import that:

from sc2.ids.unit_typeid import UnitTypeId

Next, since the game is very hierarchical, everything we do (nearly) requires us to have at least one Nexus. If we've lost the Nexus, then really our top priority is to make another one as soon as possible, or I suppose maybe destroy the enemy if you can. So, our logic, at least for now, will start with a Nexus check (note that we use self.townhalls here, which is race-generic for checking the existence of this sort of main building):

        # begin logic:
        if self.townhalls:  # do we have a nexus?
            nexus = self.townhalls.random  # select one (will just be one for now)

        else:
            if self.can_afford(UnitTypeId.NEXUS):  # can we afford one?
                await self.expand_now()  # build one!

For now, we'll be peaceful and just build a bunch of workers (if we have a nexus).

            if nexus.is_idle and self.can_afford(UnitTypeId.PROBE):
    			nexus.train(UnitTypeId.PROBE)

Full code up to this point:

from sc2.bot_ai import BotAI  # parent class we inherit from
from sc2.data import Difficulty, Race  # difficulty for bots, race for the 1 of 3 races
from sc2.main import run_game  # function that facilitates actually running the agents in games
from sc2.player import Bot, Computer  #wrapper for whether or not the agent is one of your bots, or a "computer" player
from sc2 import maps  # maps method for loading maps to play in.
from sc2.ids.unit_typeid import UnitTypeId

class IncrediBot(BotAI): # inhereits from BotAI (part of BurnySC2)
	async def on_step(self, iteration: int): # on_step is a method that is called every step of the game.
		print(f"{iteration}, n_workers: {self.workers.amount}, n_idle_workers: {self.workers.idle.amount},", \
			f"minerals: {self.minerals}, gas: {self.vespene}, cannons: {self.structures(UnitTypeId.PHOTONCANNON).amount},", \
			f"pylons: {self.structures(UnitTypeId.PYLON).amount}, nexus: {self.structures(UnitTypeId.NEXUS).amount}", \
			f"gateways: {self.structures(UnitTypeId.GATEWAY).amount}, cybernetics cores: {self.structures(UnitTypeId.CYBERNETICSCORE).amount}", \
			f"stargates: {self.structures(UnitTypeId.STARGATE).amount}, voidrays: {self.units(UnitTypeId.VOIDRAY).amount}, supply: {self.supply_used}/{self.supply_cap}")
		
		# begin logic:
		if self.townhalls:  # do we have a nexus?
			nexus = self.townhalls.random  # select one (will just be one for now)
			if nexus.is_idle and self.can_afford(UnitTypeId.PROBE):  
				nexus.train(UnitTypeId.PROBE)  # train a probe

		else:
			if self.can_afford(UnitTypeId.NEXUS):  # can we afford one?
				await self.expand_now()  # build one!



run_game(  # run_game is a function that runs the game.
	maps.get("2000AtmospheresAIE"), # the map we are playing on
	[Bot(Race.Protoss, IncrediBot()), # runs our coded bot, protoss race, and we pass our bot object 
		Computer(Race.Zerg, Difficulty.Hard)], # runs a pre-made computer agent, zerg race, with a hard difficulty.
	realtime=False, # When set to True, the agent is limited in how long each step can take to process.
)

Running this, it looks pretty similar to our runs before:

Before we can do much, we definitely need resources, and we might want more units. To increase units, we need more Supply. For that, with Protoss, we need Pylons. I'll just come up with some logic to put down, but where you place these Pylons does matter. These are what powers your buildings and dictates where units can be warped in. Strategically, these matter a lot. For now, we just need some. So, to begin, we'll start our main elif chain, which essentially is a ranking of things, in the order of how important they are to us to exist, and all of this is contained under the if statement checking to see if a Nexus exists.

            # if we dont have *any* pylons, we'll build one close to the nexus.
            elif not self.structures(UnitTypeId.PYLON) and self.already_pending(UnitTypeId.PYLON) == 0:
                if self.can_afford(UnitTypeId.PYLON):
                    await self.build(UnitTypeId.PYLON, near=nexus)

As far as I can tell, the SC2 package takes care of grabbing a worker and building things when you ask it to. I am sure there's a way you can take control of this to be more precise. For now, however, this works well. In order to make room for more buildings and our attack units, we'll probably want more pylons than this. For now, let's presume we want 5.

These Pylons need to be close to eachother to build a "matrix," so we want them close, but if we just build all of them near our Nexus, chances are we'll put them way too close together and possibly even block ourselves from leaving our starting area, so we want to employ some logic about how to "expand" outwards. For now, that logic will be to build near the nearest-to-the-enemy pylon, continuing towards the enemy:

            elif self.structures(UnitTypeId.PYLON).amount < 5:
                if self.can_afford(UnitTypeId.PYLON):
                    # build from the closest pylon towards the enemy
                    target_pylon = self.structures(UnitTypeId.PYLON).closest_to(self.enemy_start_locations[0])
                    # build as far away from target_pylon as possible:
                    pos = target_pylon.position.towards(self.enemy_start_locations[0], random.randrange(8, 15))
                    await self.build(UnitTypeId.PYLON, near=pos)

Full code up to this point:

from sc2.bot_ai import BotAI  # parent class we inherit from
from sc2.data import Difficulty, Race  # difficulty for bots, race for the 1 of 3 races
from sc2.main import run_game  # function that facilitates actually running the agents in games
from sc2.player import Bot, Computer  #wrapper for whether or not the agent is one of your bots, or a "computer" player
from sc2 import maps  # maps method for loading maps to play in.
from sc2.ids.unit_typeid import UnitTypeId

class IncrediBot(BotAI): # inhereits from BotAI (part of BurnySC2)
    async def on_step(self, iteration: int): # on_step is a method that is called every step of the game.
        print(f"{iteration}, n_workers: {self.workers.amount}, n_idle_workers: {self.workers.idle.amount},", \
            f"minerals: {self.minerals}, gas: {self.vespene}, cannons: {self.structures(UnitTypeId.PHOTONCANNON).amount},", \
            f"pylons: {self.structures(UnitTypeId.PYLON).amount}, nexus: {self.structures(UnitTypeId.NEXUS).amount}", \
            f"gateways: {self.structures(UnitTypeId.GATEWAY).amount}, cybernetics cores: {self.structures(UnitTypeId.CYBERNETICSCORE).amount}", \
            f"stargates: {self.structures(UnitTypeId.STARGATE).amount}, voidrays: {self.units(UnitTypeId.VOIDRAY).amount}, supply: {self.supply_used}/{self.supply_cap}")
        
        # begin logic:
        if self.townhalls:  # do we have a nexus?
            nexus = self.townhalls.random  # select one (will just be one for now)
            if nexus.is_idle and self.can_afford(UnitTypeId.PROBE):  
                nexus.train(UnitTypeId.PROBE)  # train a probe


            # if we dont have *any* pylons, we'll build one close to the nexus.
            elif not self.structures(UnitTypeId.PYLON) and self.already_pending(UnitTypeId.PYLON) == 0:
                if self.can_afford(UnitTypeId.PYLON):
                    await self.build(UnitTypeId.PYLON, near=nexus)


            elif self.structures(UnitTypeId.PYLON).amount < 5:
                if self.can_afford(UnitTypeId.PYLON):
                    # build from the closest pylon towards the enemy
                    target_pylon = self.structures(UnitTypeId.PYLON).closest_to(self.enemy_start_locations[0])
                    # build as far away from target_pylon as possible:
                    pos = target_pylon.position.towards(self.enemy_start_locations[0], random.randrange(8, 15))
                    await self.build(UnitTypeId.PYLON, near=pos)
                    

        else:
            if self.can_afford(UnitTypeId.NEXUS):  # can we afford one?
                await self.expand_now()  # build one!



run_game(  # run_game is a function that runs the game.
    maps.get("2000AtmospheresAIE"), # the map we are playing on
    [Bot(Race.Protoss, IncrediBot()), # runs our coded bot, protoss race, and we pass our bot object 
     Computer(Race.Zerg, Difficulty.Hard)], # runs a pre-made computer agent, zerg race, with a hard difficulty.
    realtime=False, # When set to True, the agent is limited in how long each step can take to process.
)

So now, we have enough supply to make more units. In this case, we wind up just making a bunch of workers since we're not doing anything else. You may notice we have what appears to be too many units:

In this example, we have 24/16 workers, but we will wind up likely with even more as the game progresses. With protoss, at 16 units per Nexus, you have the most efficiency per worker. This declines a bit to 24/16, and if I understand correctly, workers will wind up essentially being idle at more than 24 per nexus.

Next, we still find ourselves under attack usually within 1,000 steps of the game:

To possibly help with this, we can build Cannons. Cannons are stationary, yet powerful buildings that can do a healthy amount of damage. There's even a strategy with Protoss to quickly build cannons at the enemy's base. We won't be doing that for now, and will instead just try to build up our defenses a bit to deter enemy attacks.

You may also have noticed that we have idle workers. While we have way too many workers per nexus, this doesn't really matter much, but this could catch us off guard later. To handle for this, the very first thing we'll do in the loop after the print statement is:

        await self.distribute_workers()

This will put the workers back to work. They probably went idle after building a building.

Since we want to build cannons, we need to revist the Protoss Unit tree. This informs us that, if we want cannons, we first need to build a Forge. I'll add this to the elif list.

            elif not self.structures(UnitTypeId.FORGE):  # if we don't have a forge:
                if self.can_afford(UnitTypeId.FORGE):  # and we can afford one:
                    # build one near the Pylon that is closest to the nexus:
                    await self.build(UnitTypeId.FORGE, near=self.structures(UnitTypeId.PYLON).closest_to(nexus))

Take note here of all of the helper methods that we have available to us for logically building/placing things, especially with that build line, allowing us to build a forge not just close to a Pylon (recall we need to build buildings within the matrix of Pylons), but also close to the Pylon that's closest to the Nexus. Again, there are certainly ways to take full control over exact building locations, and this might help you to be even more strategic, but all these helper capabilities sure do make building SC2 agent logic very fast and easy!

Once we have a forge, let's build up to 3 cannons around our base for defenses:

            # if we have less than 3 cannons, let's build some more if possible:
            elif self.structures(UnitTypeId.FORGE).ready and self.structures(UnitTypeId.PHOTONCANNON).amount < 3:
                if self.can_afford(UnitTypeId.PHOTONCANNON):  # can we afford a cannon?
                    await self.build(UnitTypeId.PHOTONCANNON, near=nexus)  # build one near the nexus

With this, the full SC2 IncrediBot code is:

from sc2.bot_ai import BotAI  # parent class we inherit from
from sc2.data import Difficulty, Race  # difficulty for bots, race for the 1 of 3 races
from sc2.main import run_game  # function that facilitates actually running the agents in games
from sc2.player import Bot, Computer  #wrapper for whether or not the agent is one of your bots, or a "computer" player
from sc2 import maps  # maps method for loading maps to play in.
from sc2.ids.unit_typeid import UnitTypeId
import random

class IncrediBot(BotAI): # inhereits from BotAI (part of BurnySC2)
    async def on_step(self, iteration: int): # on_step is a method that is called every step of the game.
        print(f"{iteration}, n_workers: {self.workers.amount}, n_idle_workers: {self.workers.idle.amount},", \
            f"minerals: {self.minerals}, gas: {self.vespene}, cannons: {self.structures(UnitTypeId.PHOTONCANNON).amount},", \
            f"pylons: {self.structures(UnitTypeId.PYLON).amount}, nexus: {self.structures(UnitTypeId.NEXUS).amount}", \
            f"gateways: {self.structures(UnitTypeId.GATEWAY).amount}, cybernetics cores: {self.structures(UnitTypeId.CYBERNETICSCORE).amount}", \
            f"stargates: {self.structures(UnitTypeId.STARGATE).amount}, voidrays: {self.units(UnitTypeId.VOIDRAY).amount}, supply: {self.supply_used}/{self.supply_cap}")
        
        
        # begin logic:

        await self.distribute_workers() # put idle workers back to work

        if self.townhalls:  # do we have a nexus?
            nexus = self.townhalls.random  # select one (will just be one for now)
            if nexus.is_idle and self.can_afford(UnitTypeId.PROBE):  
                nexus.train(UnitTypeId.PROBE)  # train a probe


            # if we dont have *any* pylons, we'll build one close to the nexus.
            elif not self.structures(UnitTypeId.PYLON) and self.already_pending(UnitTypeId.PYLON) == 0:
                if self.can_afford(UnitTypeId.PYLON):
                    await self.build(UnitTypeId.PYLON, near=nexus)


            elif self.structures(UnitTypeId.PYLON).amount < 5:
                if self.can_afford(UnitTypeId.PYLON):
                    # build from the closest pylon towards the enemy
                    target_pylon = self.structures(UnitTypeId.PYLON).closest_to(self.enemy_start_locations[0])
                    # build as far away from target_pylon as possible:
                    pos = target_pylon.position.towards(self.enemy_start_locations[0], random.randrange(8, 15))
                    await self.build(UnitTypeId.PYLON, near=pos)


            elif not self.structures(UnitTypeId.FORGE):  # if we don't have a forge:
                if self.can_afford(UnitTypeId.FORGE):  # and we can afford one:
                    # build one near the Pylon that is closest to the nexus:
                    await self.build(UnitTypeId.FORGE, near=self.structures(UnitTypeId.PYLON).closest_to(nexus))

            # if we have less than 3 cannons, let's build some more if possible:
            elif self.structures(UnitTypeId.FORGE).ready and self.structures(UnitTypeId.PHOTONCANNON).amount < 3:
                if self.can_afford(UnitTypeId.PHOTONCANNON):  # can we afford a cannon?
                    await self.build(UnitTypeId.PHOTONCANNON, near=nexus)  # build one near the nexus


        else:
            if self.can_afford(UnitTypeId.NEXUS):  # can we afford one?
                await self.expand_now()  # build one!



run_game(  # run_game is a function that runs the game.
    maps.get("2000AtmospheresAIE"), # the map we are playing on
    [Bot(Race.Protoss, IncrediBot()), # runs our coded bot, protoss race, and we pass our bot object 
     Computer(Race.Zerg, Difficulty.Hard)], # runs a pre-made computer agent, zerg race, with a hard difficulty.
    realtime=False, # When set to True, the agent is limited in how long each step can take to process.
)

Running this, we should see things are fairly improved. For example, we do still get attacked around 1,000 to 1,200 steps in:

With multiple runs, sometimes we're still defeated, but sometimes we actually survive the attack, like here:

Of course, eventually we'll still be defeated here, without either expanding more and making even more defenses, making it essentially impossible for the enemy to have enough resources to kill us, or, for us to go on the offensive, and to actually attack the enemy. With cannons, we can't really move them, but with quite a few units, we can actually move them and use them both for defense as well as offense. This will be the focus of the next tutorial.





  • Introduction - Building Starcraft 2 AI in Python with Python-sc2
  • Defending the base - Building Starcraft 2 AI in Python with Python-sc2
  • Attacking the Enemy - Building Starcraft 2 AI in Python with Python-sc2