Blog | About | Hire Me

Optimizing MOBA Free Hero Rotations

by Philippe Olivier
Created 2023-06-08

Multiplayer Online Battle Arenas (MOBAs) are a genre of video games where two teams of heroes face each other on a battlefield. Popular MOBAs include League of Legends, Dota 2, and Heroes of the Storm. Players can typically choose to play one of a hundred or more heroes. Most heroes are initially locked, but can be unlocked with real or in-game money. In-game money is typically earned by playing games and completing quests. To give players a taste of the various heroes, a few of them are available to everyone on a rotation basis each week. Each hero has a particular role, such as tank, healer, etc.

We are going to consider how the generation of free rotation compositions could be optimized for the game Heroes of the Storm. There are 90 heroes: each has one of the 6 possible roles (Bruiser, Healer, Melee Assassin, Ranged Assassin, Support, and Tank), and they are each from one of the 5 possible universes (Diablo, Nexus, Overwatch, Starcraft, and Warcraft). There are 14 heroes per rotation.

The quests for this game are:

Let's modelize this problem. In each rotation, we will want heroes of every role and universe, so that all the potential quests can be completed with heroes from the free rotation. The same hero should not appear in nearby rotations, and rotations should be different from each other (we want each rotation to be as diverse as possible).

1. Data

The player can choose from among 90 heroes, each of which is defined by a universe and a role.

  HEROES = {
      "Abathur": {"Role": "Support", "Universe": "Starcraft"},
      "Alarak": {"Role": "Melee Assassin", "Universe": "Starcraft"},
      "Alexstrasza": {"Role": "Healer", "Universe": "Warcraft"},
      "Ana": {"Role": "Healer", "Universe": "Overwatch"},
      "Anduin": {"Role": "Healer", "Universe": "Warcraft"},
      "Anub'arak": {"Role": "Tank", "Universe": "Warcraft"},
      "Artanis": {"Role": "Bruiser", "Universe": "Starcraft"},
      "Arthas": {"Role": "Tank", "Universe": "Warcraft"},
      "Auriel": {"Role": "Healer", "Universe": "Diablo"},
      "Azmodan": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Blaze": {"Role": "Tank", "Universe": "Starcraft"},
      "Brightwing": {"Role": "Healer", "Universe": "Warcraft"},
      "Cassia": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Chen": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Cho": {"Role": "Tank", "Universe": "Warcraft"},
      "Chromie": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "D.Va": {"Role": "Bruiser", "Universe": "Overwatch"},
      "Deathwing": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Deckard": {"Role": "Healer", "Universe": "Diablo"},
      "Dehaka": {"Role": "Bruiser", "Universe": "Starcraft"},
      "Diablo": {"Role": "Tank", "Universe": "Diablo"},
      "E.T.C.": {"Role": "Tank", "Universe": "Warcraft"},
      "Falstad": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Fenix": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Gall": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Garrosh": {"Role": "Tank", "Universe": "Warcraft"},
      "Gazlowe": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Genji": {"Role": "Ranged Assassin", "Universe": "Overwatch"},
      "Greymane": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Gul'dan": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Hanzo": {"Role": "Ranged Assassin", "Universe": "Overwatch"},
      "Hogger": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Illidan": {"Role": "Melee Assassin", "Universe": "Warcraft"},
      "Imperius": {"Role": "Bruiser", "Universe": "Diablo"},
      "Jaina": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Johanna": {"Role": "Tank", "Universe": "Diablo"},
      "Junkrat": {"Role": "Ranged Assassin", "Universe": "Overwatch"},
      "Kael'thas": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Kel'Thuzad": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Kerrigan": {"Role": "Melee Assassin", "Universe": "Starcraft"},
      "Kharazim": {"Role": "Healer", "Universe": "Diablo"},
      "Leoric": {"Role": "Bruiser", "Universe": "Diablo"},
      "Li Li": {"Role": "Healer", "Universe": "Warcraft"},
      "Li-Ming": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Lt. Morales": {"Role": "Healer", "Universe": "Starcraft"},
      "Lúcio": {"Role": "Healer", "Universe": "Overwatch"},
      "Lunara": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Maiev": {"Role": "Melee Assassin", "Universe": "Warcraft"},
      "Mal'Ganis": {"Role": "Tank", "Universe": "Warcraft"},
      "Malfurion": {"Role": "Healer", "Universe": "Warcraft"},
      "Malthael": {"Role": "Bruiser", "Universe": "Diablo"},
      "Medivh": {"Role": "Support", "Universe": "Warcraft"},
      "Mei": {"Role": "Tank", "Universe": "Overwatch"},
      "Mephisto": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Muradin": {"Role": "Tank", "Universe": "Warcraft"},
      "Murky": {"Role": "Melee Assassin", "Universe": "Warcraft"},
      "Nazeebo": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Nova": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Orphea": {"Role": "Ranged Assassin", "Universe": "Nexus"},
      "Probius": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Qhira": {"Role": "Melee Assassin", "Universe": "Nexus"},
      "Ragnaros": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Raynor": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Rehgar": {"Role": "Healer", "Universe": "Warcraft"},
      "Rexxar": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Samuro": {"Role": "Melee Assassin", "Universe": "Warcraft"},
      "Sgt. Hammer": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Sonya": {"Role": "Bruiser", "Universe": "Diablo"},
      "Stitches": {"Role": "Tank", "Universe": "Warcraft"},
      "Stukov": {"Role": "Healer", "Universe": "Starcraft"},
      "Sylvanas": {"Role": "Ranged Assassin", "Universe": "Warcraft"},
      "Tassadar": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "The Butcher": {"Role": "Melee Assassin", "Universe": "Diablo"},
      "The Lost Vikings": {"Role": "Support", "Universe": "Nexus"},
      "Thrall": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Tracer": {"Role": "Ranged Assassin", "Universe": "Overwatch"},
      "Tychus": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Tyrael": {"Role": "Tank", "Universe": "Diablo"},
      "Tyrande": {"Role": "Healer", "Universe": "Warcraft"},
      "Uther": {"Role": "Healer", "Universe": "Warcraft"},
      "Valeera": {"Role": "Melee Assassin", "Universe": "Warcraft"},
      "Valla": {"Role": "Ranged Assassin", "Universe": "Diablo"},
      "Varian": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Whitemane": {"Role": "Healer", "Universe": "Warcraft"},
      "Xul": {"Role": "Bruiser", "Universe": "Diablo"},
      "Yrel": {"Role": "Bruiser", "Universe": "Warcraft"},
      "Zagara": {"Role": "Ranged Assassin", "Universe": "Starcraft"},
      "Zarya": {"Role": "Support", "Universe": "Overwatch"},
      "Zeratul": {"Role": "Melee Assassin", "Universe": "Starcraft"},
      "Zul'jin": {"Role": "Ranged Assassin", "Universe": "Warcraft"}}

  UNIVERSES = (
      "Diablo",
      "Nexus",
      "Overwatch",
      "Starcraft",
      "Warcraft")

  ROLES = (
      "Bruiser",
      "Healer",
      "Melee Assassin",
      "Ranged Assassin",
      "Support",
      "Tank")

We put this data in 0-1 format for the model:

  # universes["Diablo"][h] == 1 if hero `h` is from the Diablo universe, 0 otherwise
  universes = {}
  for u in UNIVERSES:
      universes[u] = [1 if h["Universe"] == u else 0 for h in HEROES.values()]

  # roles["Bruiser"][h] == 1 if hero `h` is a bruiser, 0 otherwise
  roles = {}
  for r in ROLES:
      roles[r] = [1 if h["Role"] == r else 0 for h in HEROES.values()]

Since the data in a Python dictionary is naturally sorted in the order of insertion, this 0-1 mapping is consistent with the original data.

2. Parameters

The only parameter that can be specified is the number of rotations to generate. We could make the model more customizable, but we will instead choose automatic but sensible defaults.

  NUM_ROTATIONS = 20

3. Initialization

We first initialize the model:

  import math
  from ortools.sat.python import cp_model

  model = cp_model.CpModel()

We then define some constants.

  NUM_HEROES = len(HEROES)
  ROTATION_SIZE = 14

A hero can only appear once in any sequence of SPACING rotations (to make sure that a hero does not appear in rotations that are too close together):

  SPACING = math.floor(NUM_HEROES / ROTATION_SIZE) - 1  # -1 to give us a bit of leeway

We also want to make sure that rotations are as different as possible from each other. Each rotation must contain a unique set of UNIQUE heroes that are not found together in any other rotation:

  UNIQUE = 3

4. Decision variables

We have a NUM_ROTATIONS × NUM_HEROES matrix of binary decision variables, indicating which hero is selected in which rotation:

  # rotations[r][h] == 1 if hero `h` is selected in rotation `r`
  rotations = [[model.NewBoolVar(f"rotations_{r}_{h}") for h in range(NUM_HEROES)] for r in range(NUM_ROTATIONS)]

5. Constraints

We enforce the size of the rotations:

  for rotation in range(NUM_ROTATIONS):
      model.Add(cp_model.LinearExpr.Sum(rotations[rotation]) == ROTATION_SIZE)

We should expect that the number of heroes per universe and per role be balanced in each rotation. At 90 heroes and 14 heroes per rotation, here is the breakdown in the average types of heroes we should be seeing in each rotation per universe:

Universe # Heroes Total # Heroes/Rotation
Diablo 18 2.80
Nexus 3 0.47
Overwatch 9 1.40
Starcraft 17 2.64
Warcraft 43 6.69

And per role:

Role # Heroes Total # Heroes/Rotation
Bruiser 17 2.64
Healer 16 2.49
Melee Assassin 10 1.56
Ranged Assassin 30 4.67
Support 4 0.62
Tank 13 2.02

We will enforce rules stating that each rotation should have between floor(# Heroes/Rotation) and ceil(# Heroes/Rotation) heroes from every universe and every role.

  UNIVERSES_FREQ = (
    ("Diablo", 2.80),
    ("Nexus", 0.47),
    ("Overwatch", 1.40),
    ("Starcraft", 2.64),
    ("Warcraft", 6.69))

  for rotation in range(NUM_ROTATIONS):
      for universe, freq in UNIVERSES_FREQ:
          model.Add(cp_model.LinearExpr.WeightedSum(rotations[rotation], universes[universe]) >= math.floor(freq))
          model.Add(cp_model.LinearExpr.WeightedSum(rotations[rotation], universes[universe]) <= math.ceil(freq))

  ROLES_FREQ = (
    ("Bruiser", 2.64),
    ("Healer", 2.49),
    ("Melee Assassin", 1.56),
    ("Ranged Assassin", 4.67),
    ("Support", 0.62),
    ("Tank", 2.02))

  for rotation in range(NUM_ROTATIONS):
      for role, freq in ROLES_FREQ:
          model.Add(cp_model.LinearExpr.WeightedSum(rotations[rotation], roles[role]) >= math.floor(freq))
          model.Add(cp_model.LinearExpr.WeightedSum(rotations[rotation], roles[role]) <= math.ceil(freq))

A hero should not appear in nearby rotations:

  for rotation in range(NUM_ROTATIONS-SPACING):
      for hero in range(NUM_HEROES):
          model.Add(cp_model.LinearExpr.Sum([rotations[rotation+i][hero] for i in range(SPACING)]) <= 1)

Heroes should appear a similar number of times overall, i.e., between floor(NUM_ROTATIONS * ROTATION_SIZE / NUM_HEROES) and ceil(NUM_ROTATIONS * ROTATION_SIZE / NUM_HEROES).

  for hero in range(NUM_HEROES):
      model.Add(cp_model.LinearExpr.Sum([rotations[rotation][hero] for rotation in range(NUM_ROTATIONS)]) >= math.floor(NUM_ROTATIONS * ROTATION_SIZE / NUM_HEROES))
      model.Add(cp_model.LinearExpr.Sum([rotations[rotation][hero] for rotation in range(NUM_ROTATIONS)]) <= math.ceil(NUM_ROTATIONS * ROTATION_SIZE / NUM_HEROES))

Each rotation should be as unique as possible:

  for rotation1 in range(NUM_ROTATIONS-1):
      for rotation2 in range(rotation1+1, NUM_ROTATIONS):
          common_heroes = [model.NewBoolVar(f"common_heroes_{rotation1}_{rotation2}_{hero}") for hero in range(NUM_HEROES)]
          for hero in range(NUM_HEROES):
              model.AddBoolOr(rotations[rotation1][hero].Not(), rotations[rotation2][hero].Not(), common_heroes[hero])
              model.AddImplication(common_heroes[hero], rotations[rotation1][hero])
              model.AddImplication(common_heroes[hero], rotations[rotation2][hero])
          model.Add(cp_model.LinearExpr.Sum(common_heroes) <= ROTATION_SIZE - UNIQUE)

Finally, there is a special hero called "Cho'gall" that is controlled by two players (one plays Cho, the other plays Gall). We add a special constraint to ensure that these two heroes always appear together in rotations:

  NAMES = list(HEROES.keys())
  CHO_INDEX = NAMES.index("Cho")
  GALL_INDEX = NAMES.index("Gall")

  for rotation in range(NUM_ROTATIONS):
      model.Add(rotations[rotation][CHO_INDEX] == rotations[rotation][GALL_INDEX])

6. Objective

We have defined a problem without using an objective, as we consider that any solution satisfying the constraints is going to be a good solution.

7. Solving the problem

Finally, we invoke the solver and print the solution:

  solver = cp_model.CpSolver()
  status = solver.Solve(model)
  if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
      print("No solution exists")

  for rotation in range(NUM_ROTATIONS):
      c = 0
      for hero in range(NUM_HEROES):
          if solver.Value(rotations[rotation][hero]) == 1:
              c += 1
              print(NAMES[hero], end="")
              if c < 14:
                  print(", ", end="")
      print()

For 20 rotations, we get the result:

Alexstrasza, Blaze, Dehaka, Diablo, Gazlowe, Lt. Morales, Nova, Sonya, The Lost Vikings, Thrall, Valla, Whitemane, Yrel, Zul'jin
Chen, Cho, Gall, Malthael, Muradin, Murky, Nazeebo, Probius, Raynor, Rehgar, Sgt. Hammer, Stukov, Tassadar, Tychus
Artanis, Azmodan, Cassia, Hogger, Kharazim, Li-Ming, Maiev, Mal'Ganis, Mei, Ragnaros, Rexxar, Samuro, The Butcher, Varian
Illidan, Imperius, Jaina, Johanna, Junkrat, Kael'thas, Kel'Thuzad, Leoric, Li Li, Lúcio, Medivh, Mephisto, Uther, Zarya
Abathur, Anduin, Anub'arak, Auriel, Brightwing, D.Va, Deckard, E.T.C., Falstad, Fenix, Garrosh, Gul'dan, Stitches, Tracer
Alarak, Alexstrasza, Ana, Arthas, Blaze, Chromie, Deathwing, Dehaka, Diablo, Gazlowe, Greymane, Hanzo, Lt. Morales, Valla
Chen, Cho, Gall, Kerrigan, Lunara, Malfurion, Orphea, Rehgar, The Lost Vikings, Tychus, Tyrande, Valeera, Yrel, Zeratul
Artanis, Azmodan, Cassia, Kharazim, Li-Ming, Maiev, Mal'Ganis, Muradin, Probius, Rexxar, Samuro, Stukov, The Butcher, Varian
Jaina, Junkrat, Kael'thas, Leoric, Lúcio, Malthael, Medivh, Mei, Mephisto, Murky, Nazeebo, Ragnaros, Raynor, Zul'jin
Anduin, Anub'arak, Auriel, D.Va, Deckard, E.T.C., Fenix, Garrosh, Genji, Gul'dan, Hogger, Illidan, Stitches, Tassadar
Abathur, Alarak, Ana, Arthas, Greymane, Imperius, Johanna, Kel'Thuzad, Li Li, Lt. Morales, Nova, Qhira, Xul, Zarya
Kerrigan, Lunara, Malfurion, Orphea, Sonya, Sylvanas, Thrall, Tracer, Tychus, Tyrael, Uther, Valeera, Whitemane, Zeratul
Kharazim, Li-Ming, Maiev, Mal'Ganis, Muradin, Probius, Rehgar, Rexxar, Samuro, Sgt. Hammer, The Lost Vikings, Valla, Yrel, Zagara
Azmodan, Blaze, Brightwing, Cassia, Chen, Cho, Chromie, Deathwing, Dehaka, Diablo, Falstad, Gall, Hanzo, Junkrat
Alexstrasza, Anduin, Artanis, Auriel, Gazlowe, Genji, Gul'dan, Hogger, Illidan, Lúcio, Murky, Stitches, Tyrande, Zul'jin
Abathur, Alarak, Ana, Anub'arak, Arthas, D.Va, Deckard, E.T.C., Garrosh, Greymane, Imperius, Kel'Thuzad, Nazeebo, Ragnaros
Fenix, Jaina, Johanna, Kael'thas, Leoric, Li Li, Lunara, Malfurion, Malthael, Medivh, Mei, Mephisto, The Butcher, Tychus
Kerrigan, Orphea, Qhira, Raynor, Samuro, Sgt. Hammer, Sonya, Stukov, Sylvanas, Tassadar, Tracer, Uther, Valeera, Varian
Brightwing, Cho, Deathwing, Falstad, Gall, Hanzo, Nova, Thrall, Tyrael, Whitemane, Xul, Yrel, Zagara, Zeratul
Arthas, Cassia, Chromie, Genji, Greymane, Qhira, Rexxar, Stitches, Sylvanas, Tyrael, Tyrande, Xul, Zagara, Zarya	
This website is generated by Emacs.