Defeating Hard AI - Python AI in StarCraft II p.6




Welcome to part 6 of the StarCraft II AI with Python tutorial series. In this tutorial, we're going to consider what we might need to do in order to beat the "hard" AI computer with our own AI.

Our code so far:

import sc2
from sc2 import run_game, maps, Race, Difficulty
from sc2.player import Bot, Computer
from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER
import random


class SentdeBot(sc2.BotAI):
    async def on_step(self, iteration):
        await self.distribute_workers()
        await self.build_workers()
        await self.build_pylons()
        await self.build_assimilators()
        await self.expand()
        await self.offensive_force_buildings()
        await self.build_offensive_force()
        await self.attack()

    async def build_workers(self):
        for nexus in self.units(NEXUS).ready.noqueue:
            if self.can_afford(PROBE):
                await self.do(nexus.train(PROBE))

    async def build_pylons(self):
        if self.supply_left < 5 and not self.already_pending(PYLON):
            nexuses = self.units(NEXUS).ready
            if nexuses.exists:
                if self.can_afford(PYLON):
                    await self.build(PYLON, near=nexuses.first)

    async def build_assimilators(self):
        for nexus in self.units(NEXUS).ready:
            vaspenes = self.state.vespene_geyser.closer_than(15.0, nexus)
            for vaspene in vaspenes:
                if not self.can_afford(ASSIMILATOR):
                    break
                worker = self.select_build_worker(vaspene.position)
                if worker is None:
                    break
                if not self.units(ASSIMILATOR).closer_than(1.0, vaspene).exists:
                    await self.do(worker.build(ASSIMILATOR, vaspene))

    async def expand(self):
        if self.units(NEXUS).amount < 3 and self.can_afford(NEXUS):
            await self.expand_now()

    async def offensive_force_buildings(self):
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < 3:
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

    async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if self.can_afford(STALKER) and self.supply_left > 0:
                await self.do(gw.train(STALKER))

    def find_target(self, state):
        if len(self.known_enemy_units) > 0:
            return random.choice(self.known_enemy_units)
        elif len(self.known_enemy_structures) > 0:
            return random.choice(self.known_enemy_structures)
        else:
            return self.enemy_start_locations[0]

    async def attack(self):
        if self.units(STALKER).amount > 15:
            for s in self.units(STALKER).idle:
                await self.do(s.attack(self.find_target(self.state)))

        elif self.units(STALKER).amount > 3:
            if len(self.known_enemy_units) > 0:
                for s in self.units(STALKER).idle:
                    await self.do(s.attack(random.choice(self.known_enemy_units)))


run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Computer(Race.Terran, Difficulty.Easy)
    ], realtime=False)

The main issue I see with this code is that the initial "army" that we send over is often too weak. Even if we do quite a bit of damage with this army, the reinforcements that we send in are far too weak. Part of the problem here is that our AI has no notion of time having been elapsed. If you have an army of 15 stalkers in the first 5 minutes, along with 3 gateways, you're doing pretty solid. At 11 minutes though? This is quite weak!

We don't want to try to build 10 gateways right..out of the gate..however, because that would drain resources too much earlier on, and leave us not only weak at the moment, but also too weak to expand to 2-3 sites.

So, what do we do? I propose something more linear-like in progression. We could track computer time, to see how much time has elapsed, though I feel a more important metric as far as any AI is concerned is just the iteration itself. Through some testing, I roughly estimated 165 iterations per minute. So, I am going to set the following constant:

ITERATIONS_PER_MINUTE = 165

That said, we'd like to be able to import this bot too, so let's instead create a new method for our bot class, the __init__ method:

    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165

Next, we can then take note of the iteration itself, which is passed already to our on_step method:

    async def on_step(self, iteration):
        self.iteration = iteration
        ...

Now, we can build more and more gateways as time goes on, in order more quickly amass an army the later and later the game progresses. So let's modify the offensive_force_buildings code to have the number of gateways be a function of iterations, rather than a fixed 3:

            elif len(self.units(GATEWAY)) < (self.iteration / self.ITERATIONS_PER_MINUTE):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

So the full method:

    async def offensive_force_buildings(self):
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < (self.iteration / self.ITERATIONS_PER_MINUTE):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

Finally, another issue we find if the game progresses long enough is the number of workers that we produc in that we produce far too many. In our init method, let's add a maximum amount of workers:

    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165
        self.MAX_WORKERS = 50

50 should be more than enough, but we might find we want to tweak this number dynamically with iterations or with functioning nexuses too. I propose the following logic to preempt our build_workers method:

if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:

So if 16*our number of nexuses is more than the workers we currently have and if the number of workers we have is less than our maximum workers, then we can build another, making our full method:

    async def build_workers(self):
        if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:
            for nexus in self.units(NEXUS).ready.noqueue:
                if self.can_afford(PROBE):
                    await self.do(nexus.train(PROBE))

Running this, we do much better, but we seem to trade armies with the enemy, which is a bit awkward. The enemy AI appears to just be slightly better than us, and we can actually win a few matches, but I'd like to do it a bit more consistently. The games can often last a while, and end with both of us just attacking each other's base, but the enemy tends to win. Sometimes we also just aren't large enough. We do a lot of damage, but don't win immediately. Then we fall behind on units and resources as the enemy amasses a massive army, and then obliterates us. We could attempt to get a larger army next time, but, instead, our stalkers are kind of a weak unit to be using later on in the game. Why not mix it up a bit and also build Void Rays? Finally, we might actually be expanding too quickly.

We might also want to more reasonably expand. Rather than immediately trying to expand to three resource places, why not something more like an expansion per minute? We could try something like:

    async def expand(self):
        if self.units(NEXUS).amount < (self.iteration / self.ITERATIONS_PER_MINUTE) and self.can_afford(NEXUS):
            await self.expand_now()

Finally, let's build some better units. We have ground units, let's do some air units. Air units are useful for quickly navigating the map, getting around obstacles...etc. For Protoss, we'll go with the Void Ray, which requires a "Stargate" (as well as cybernetics core, but we have that already).

We need to import the STARGATE and VOIDRAY objects:

from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER, STARGATE, VOIDRAY

Next, let's change our offensive force buildings method:

    async def offensive_force_buildings(self):
        print(self.iteration / self.ITERATIONS_PER_MINUTE)
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

            if self.units(CYBERNETICSCORE).ready.exists:
                if len(self.units(STARGATE)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                    if self.can_afford(STARGATE) and not self.already_pending(STARGATE):
                        await self.build(STARGATE, near=pylon)

So now we attempt to build both of these buildings at the same rate. Next we modify the offensive force method:

async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if not self.units(STALKER).amount > self.units(VOIDRAY).amount:

                if self.can_afford(STALKER) and self.supply_left > 0:
                    await self.do(gw.train(STALKER))

        for sg in self.units(STARGATE).ready.noqueue:
            if self.can_afford(VOIDRAY) and self.supply_left > 0:
                await self.do(sg.train(VOIDRAY))

It's important to note that we can build far more stalkers at a rate faster than the void rays, and they are cheaper, so we want to make sure we aren't over-producing and locking out the production of Void Rays.

Finally, we need to modify the attack method:

async def attack(self):
        # {UNIT: [n to fight, n to defend]}
        aggressive_units = {STALKER: [15, 5],
                            VOIDRAY: [8, 3]}


        for UNIT in aggressive_units:
            if self.units(UNIT).amount > aggressive_units[UNIT][0] and self.units(UNIT).amount > aggressive_units[UNIT][1]:
                for s in self.units(UNIT).idle:
                    await self.do(s.attack(self.find_target(self.state)))

            elif self.units(UNIT).amount > aggressive_units[UNIT][1]:
                if len(self.known_enemy_units) > 0:
                    for s in self.units(UNIT).idle:
                        await self.do(s.attack(random.choice(self.known_enemy_units)))

Full code now:

import sc2
from sc2 import run_game, maps, Race, Difficulty
from sc2.player import Bot, Computer
from sc2.constants import NEXUS, PROBE, PYLON, ASSIMILATOR, GATEWAY, \
 CYBERNETICSCORE, STALKER, STARGATE, VOIDRAY
import random


class SentdeBot(sc2.BotAI):
    def __init__(self):
        self.ITERATIONS_PER_MINUTE = 165
        self.MAX_WORKERS = 50

    async def on_step(self, iteration):
        self.iteration = iteration
        await self.distribute_workers()
        await self.build_workers()
        await self.build_pylons()
        await self.build_assimilators()
        await self.expand()
        await self.offensive_force_buildings()
        await self.build_offensive_force()
        await self.attack()

    async def build_workers(self):
        if (len(self.units(NEXUS)) * 16) > len(self.units(PROBE)) and len(self.units(PROBE)) < self.MAX_WORKERS:
            for nexus in self.units(NEXUS).ready.noqueue:
                if self.can_afford(PROBE):
                    await self.do(nexus.train(PROBE))


    async def build_pylons(self):
        if self.supply_left < 5 and not self.already_pending(PYLON):
            nexuses = self.units(NEXUS).ready
            if nexuses.exists:
                if self.can_afford(PYLON):
                    await self.build(PYLON, near=nexuses.first)

    async def build_assimilators(self):
        for nexus in self.units(NEXUS).ready:
            vaspenes = self.state.vespene_geyser.closer_than(15.0, nexus)
            for vaspene in vaspenes:
                if not self.can_afford(ASSIMILATOR):
                    break
                worker = self.select_build_worker(vaspene.position)
                if worker is None:
                    break
                if not self.units(ASSIMILATOR).closer_than(1.0, vaspene).exists:
                    await self.do(worker.build(ASSIMILATOR, vaspene))

    async def expand(self):
        if self.units(NEXUS).amount < (self.iteration / self.ITERATIONS_PER_MINUTE) and self.can_afford(NEXUS):
            await self.expand_now()

    async def offensive_force_buildings(self):
        #print(self.iteration / self.ITERATIONS_PER_MINUTE)
        if self.units(PYLON).ready.exists:
            pylon = self.units(PYLON).ready.random

            if self.units(GATEWAY).ready.exists and not self.units(CYBERNETICSCORE):
                if self.can_afford(CYBERNETICSCORE) and not self.already_pending(CYBERNETICSCORE):
                    await self.build(CYBERNETICSCORE, near=pylon)

            elif len(self.units(GATEWAY)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                if self.can_afford(GATEWAY) and not self.already_pending(GATEWAY):
                    await self.build(GATEWAY, near=pylon)

            if self.units(CYBERNETICSCORE).ready.exists:
                if len(self.units(STARGATE)) < ((self.iteration / self.ITERATIONS_PER_MINUTE)/2):
                    if self.can_afford(STARGATE) and not self.already_pending(STARGATE):
                        await self.build(STARGATE, near=pylon)

    async def build_offensive_force(self):
        for gw in self.units(GATEWAY).ready.noqueue:
            if not self.units(STALKER).amount > self.units(VOIDRAY).amount:
                if self.can_afford(STALKER) and self.supply_left > 0:
                    await self.do(gw.train(STALKER))

        for sg in self.units(STARGATE).ready.noqueue:
            if self.can_afford(VOIDRAY) and self.supply_left > 0:
                await self.do(sg.train(VOIDRAY))

    def find_target(self, state):
        if len(self.known_enemy_units) > 0:
            return random.choice(self.known_enemy_units)
        elif len(self.known_enemy_structures) > 0:
            return random.choice(self.known_enemy_structures)
        else:
            return self.enemy_start_locations[0]

    async def attack(self):
        # {UNIT: [n to fight, n to defend]}
        aggressive_units = {STALKER: [15, 5],
                            VOIDRAY: [8, 3]}


        for UNIT in aggressive_units:
            if self.units(UNIT).amount > aggressive_units[UNIT][0] and self.units(UNIT).amount > aggressive_units[UNIT][1]:
                for s in self.units(UNIT).idle:
                    await self.do(s.attack(self.find_target(self.state)))

            elif self.units(UNIT).amount > aggressive_units[UNIT][1]:
                if len(self.known_enemy_units) > 0:
                    for s in self.units(UNIT).idle:
                        await self.do(s.attack(random.choice(self.known_enemy_units)))


run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Computer(Race.Terran, Difficulty.Hard)
    ], realtime=False)

And now, we defeat hard Terran without much struggle. I've seen some losses, and it was due to things like units getting stuck. I was able to fairly consistently defeat Protoss, Zerg, and Terran all on hard with this strategy.

Next, you should also try to compete against the example bots from the package's github. Clone the Python-sc2 package, and check out the examples. Copy that directory into your working directory, then you can do something like:

from examples.terran.proxy_rax import ProxyRaxBot

Then, you can change the run_game to:

run_game(maps.get("AbyssalReefLE"), [
    Bot(Race.Protoss, SentdeBot()),
    Bot(Race.Terran, ProxyRaxBot()),
    ], realtime=False)

For some reason, fairly often when I put two of my own bots against each other, I just get black screens when the game launches and nothing happens. To fix this, I go to C:\Users\H\Documents\StarCraft II and delete variables.txt

These example bots have more of the common strategies built into them. For example, a popular Protoss strategy is a cannon rush. Check that one out too.

At this point, it's just a bunch of if-statements to create your strategy, and that's up to you. As for me, I am going to be looking into methods to incorporate deep learning into the mix. At least for sure, we can use regression algorithms to pick many of our values, or a classifier to determine whether or not we should attack. I know people also are going to want to see more general AI, which doesn't yet exist for the full game of StarCraft II, but we can poke around. See you in the next tutorial!

The next tutorial:





  • Introduction and Collecting Minerals - Python AI in StarCraft II p.1
  • Workers and Pylons - Python AI in StarCraft II p.2
  • Geysers and Expanding - Python AI in StarCraft II p.3
  • Building an AI Army - Python AI in StarCraft II p.4
  • Commanding your AI Army - Python AI in StarCraft II p.5
  • Defeating Hard AI - Python AI in StarCraft II p.6
  • Deep Learning with SC2 Intro - Python AI in StarCraft II p.7
  • Scouting and more Visual inputs - Python AI in StarCraft II p.8
  • Building our training data - Python AI in StarCraft II p.9
  • Building Neural Network Model - Python AI in StarCraft II p.10
  • Training Neural Network Model - Python AI in StarCraft II p.11
  • Using Neural Network Model - Python AI in StarCraft II p.12
  • Version 2 Changes - Python AI in StarCraft II p.13
  • Improving Scouting - Python AI in StarCraft II p.14
  • Adding Choices - Python AI in StarCraft II p.15
  • Visualization Changes - Python AI in StarCraft II p.16
  • More Training and Findings - Python AI in StarCraft II p.17