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!