Andrew Kerr
  • Resume
  • Thesis
  • Team Fight Tactics API Projects
  • Portfolio

On this page

  • Introduction
  • Main Code
  • Scenario 2
    • Scenario 2b
  !pip install symbulate
from symbulate import *
%matplotlib inline
from matplotlib import pyplot as plt
Requirement already satisfied: symbulate in /usr/local/lib/python3.7/dist-packages (0.5.7)
Requirement already satisfied: numpy in /usr/local/lib/python3.7/dist-packages (from symbulate) (1.19.5)
Requirement already satisfied: matplotlib in /usr/local/lib/python3.7/dist-packages (from symbulate) (3.2.2)
Requirement already satisfied: scipy in /usr/local/lib/python3.7/dist-packages (from symbulate) (1.4.1)
Requirement already satisfied: pyparsing!=2.0.4,!=2.1.2,!=2.1.6,>=2.0.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib->symbulate) (3.0.6)
Requirement already satisfied: python-dateutil>=2.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib->symbulate) (2.8.2)
Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.7/dist-packages (from matplotlib->symbulate) (0.11.0)
Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.7/dist-packages (from matplotlib->symbulate) (1.3.2)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.7/dist-packages (from python-dateutil>=2.1->matplotlib->symbulate) (1.15.0)

Name(s): Andrew Kerr

Introduction

One of my favoirte games to play is Team Fight Tactics, TFT for short. The goal of TFT is to be the last player standing in an 8-player free for all. Throughout the game you manage resources, items and gold, and create the strongest team of units, each with different strengths, weaknesses, and abilities. One key skill used throughout the game is knowing when to spend your gold looking for units, and when to save your gold for later rounds. Below I have described the relevant game mechanics I will reference during this investigation.

###Units

Units are separated into five tiers: 1-cost, 2-cost, 3-cost, 4-cost, and 5-cost. There is a fixed unit pool size for each tier which is shared among all the players:

  • Tier 1: 29

  • Tier 2: 22

  • Tier 3: 18

  • Tier 4: 12

  • Tier 5: 10

This number represents the amount of copies of each unit in the tier. For example, there are 18 copies of each tier three unit.

If you have 3 of the same unit, then they will combine to be a 2-star version (stronger version) of said unit. Furthermore, if you have three 2-star versions of the same unit, then they will combine to be a 3-star version. In other words, if you want the strongest version of a unit you need to collect 9 of that unit.

###The Shop

Units are mainly bought from the shop using gold you have accumulated. As you might expect, each tier costs the amount of gold in its name and every unit bought from the shop will be 1-star. For example, a 3-cost unit will cost you 3 gold. The shop will offer 5 units at a time. If you buy a unit, another unit will not fill the unoccupied spot until the shop is refreshed. You can use gold to “reroll” the shop, refreshing it with 5 new units (which can be the same as those in the previous reroll).

Every time the shop is created, for each spot, first the the unit tier is decided then the specific unit is decided.

###Levels

Another use of gold is to level-up. You passively gain 2 experience at the start of each round, but can buy 4 experience for 4 gold at any time. Your level corresponds to how many units you may use at once as well as the odds of seeing each tier of champion in the shop.

image.png

For example, lets say there are 2 different 3-cost units (Joe and Bob), you are level 4, and no 3-cost units have been removed from the unit pool. Then there is approximetly a 56% chance of seeing at least one 3-cost unit in your shop, and approximetly a 32% chance of seeing at least one Joe in your shop.

###The Economy

At the start of each round, you passively gain 5 gold. You gain bonus gold if you are on either a win or lose streak:

  • 2-3 Rounds: +1 Gold

  • 4 Rounds: +2 Gold

  • 5+ Rounds: +3 Gold

At the end of each round, your interest is calculated. For every 10 gold you have, you get +1 Gold up to a maximum of +5 Gold.

  • 10-19 Gold: +1 Gold

  • 20-29 Gold: +2 Gold

  • 30-39 Gold: +3 Gold

  • 40-49 Gold: +4 Gold

  • 50+ Gold: +5 Gold

This means when calculating your gold for the next round, your interest is calculated before your passive gold and win/loss streak gold. For example, if you have 29 gold and are on a 6 game loss streak, you will have 39 gold, or gain 10 gold, next round.

Main Code

# -------------------- Shop Creation --------------------

# -------------------- X number of shops --------------------
def multi_shop(num, level, tier, taken1=0, taken2=0, sims=1):
  '''Parameters
      num     : amount of shops in a row rolled
      level   : your level
      tier    : the desired units tier
      taken1  : the amount of units you are looking for which are out of
                the pool (of the desired unit)
      taken2  : the amount of units of the tier you are looking for which are
                out (not including that of the desired unit)                    # taken1 + taken2 = total number of units in the tier
      sims    : amount of shop simulations

  Returns a list of the amount of the desired units bought in N shops.'''
  out = [ [] for x in range(sims) ]
  for i in range(sims):                                                         # For each simulation...
    temp_num = num
    temp_taken1 = taken1
    while temp_num != 0:                                                        # Roll the shop num times and assume you buy the desired unit whever possible (aka increase taken1)
      result = shop(level, tier, temp_taken1, taken2)
      out[i].append(result[0])
      temp_taken1 += result[0]
      temp_num -= 1

  return out

# -------------------- The shop at level X --------------------
def shop(level, tier, taken1=0, taken2=0, sims=1):
  '''Parameters
      level   : your level
      tier    : the desired units tier
      taken1  : the amount of units you are looking for which are out of
                the pool (of the desired unit)
      taken2  : the amount of units of the tier you are looking for which are
                out (not including that of the desired unit)                    # taken1 + taken2 = total number of units in the tier
      sims    : amount of shop simulations

  Returns the amount of the desired unit in "sims" shops.'''
  shop_odds = [(1, 0, 0, 0, 0),
               (1, 0, 0, 0, 0),
               (.75, .25, 0, 0, 0),
               (.55, .30, .15, 0, 0),
               (.45, .33, .20, .02, 0),
               (.25, .40, .30, .05, 0),
               (.19, .30, .35, .15, .01),
               (.15, .20, .35, .25, .05),
               (.10, .15, .30, .30, .15)]

  units_per_tier = [13, 13, 13, 11, 8]                                          # Amount of unique units in each tier (there are nine 5-costs, but 1 does not show up in the shop so we will ignore it)

  shop_outcomes = spot(shop_odds[level - 1], 5, sims)                           # Simulates a refresh of the shop

  total_spots = []
  for i in range(len(shop_outcomes)):                                           # Counts the number of spots which could hold the desired unit for each simualtion
    spots = 0
    for z in range(len(shop_outcomes.get(i))):
      if shop_outcomes.get(i)[z] == tier:
        spots += 1
    total_spots.append(spots)

  units_found = []
  for simulation in total_spots:                                                # Simulates assigning each spot a unit and calculates the total number of desired units found for each simulation
    if spots != 0:
      num = unit(taken1, taken2, tier, units_per_tier[tier - 1], spots).get(0)
      units_found.append(num)
    else:
      units_found.append(0)

  return units_found

# -------------------- Rolling shop spot(s) --------------------
def spot(odds, n=5, sims=1):
  '''Parameters
      odds   : the shop odds
      n      : amount of spots you want rolled (shop has 5)
      sims   : amount of shop simulations

  Returns a tuple with a size equivalent to the number of spots wanted, and
  where each element denotes the unit tier assigned to said spot.'''
  P = BoxModel([1, 2, 3, 4, 5], probs=odds, size=n)
  X = RV(P)
  return X.sim(sims)

# -------------------- Rolling units --------------------
def unit(taken1, taken2, tier, unique, n):
  '''Parameters
      taken1  : the amount of units you are looking for which are out of
                the pool (of the desired unit)
      taken2  : the amount of units of the tier you are looking for which are
                out (including that of the desired unit)
      tier    : the desired units tier
      unique  : the number of unique units in the tier
                in the same tier that the unit you want is in
      n       : the number of spots to roll

  Returns the amount of the desired unit rolled.'''
  units_in_tier = [29, 22, 18, 12, 10]                                          # Amount of units for each unique unit in each tier (eg. There are 13 1-cost units, of which there are 29 of each in the pool)

  num_sucesses = units_in_tier[tier - 1] - taken1                               # Amount of units you want in the pool
  num_failures = (units_in_tier[tier - 1] * (unique - 1)) - taken2              # Amount of units you do not want in the same tier in the pool
  Q = Hypergeometric(n = n, N0 = num_failures, N1 = num_sucesses)
  return Q.sim(1)
# -------------------- Statistics --------------------

# -------------------- Expected Value --------------------
def average(total_spots, multi=False):
  '''Parameters
      total_spots  : a list containing the amound of desired units in the shop
                     in a large number of simulations
      multi        : whether the shop was rolled more than once

  Returns the average number of desired units in the shop.'''
  total = 0
  if not multi:
    for val in total_spots:
      total += val
  else:
    for simulation in total_spots:
      sim_total = 0
      for roll in simulation:
        sim_total += roll
      total += sim_total
  return total / len(total_spots)

# -------------------- Probability of at least ... --------------------
def at_least_x(total_spots, multi=False, x=1):
  '''Parameters
      total_spots  : a list containing the amound of desired units in the shop
                     in a large number of simulations
      multi        : whether the shop was rolled more than once
      x            : how many minimum of the desired unit you want to see in
                     the shop

  Returns the probability of seeing at least x of your desired unit in the
  shop.'''
  sims = len(total_spots)
  count = 0

  if not multi:                                                                 # For each simulation --> see how many desired units --> if >= desired amount, count++
    for val in total_spots:
      if val >= x:
        count += 1
  else:                                                                         # For each simulation --> for each shop see how many desired units --> if >= desired amount, count++
    for simulation in total_spots:
      total = 0
      for roll in simulation:
        total += roll
      if total >= x:
        count += 1

  return count / sims

#Scenario 1

##Scenario 1a

The game has just begun, and you want to have a strong start. One way to do this is to get an early 2-star 2-cost unit. Since the game has just begun, no one has bought any 2-cost units yet and I am only level 3. What is the probability that you can get 3 of the same 2-cost unit in only 3 shops?

at_least_x(multi_shop(num=3, level=3, tier=2, sims=10000), True, 3)
0.0022

Another option is to find an early 3-cost unit after leveling up to level 4. Because 3-cost units are inherently stronger than 2-cost units, you do not need to 2-star it. What is the probability of getting 1 of your desired 3-cost unit in 3 shops?

at_least_x(multi_shop(num=2, level=4, tier=3, sims=10000), True)
0.1056

If a 1-star 3-cost unit is so strong, why not go for a 2-star 3-cost unit?

at_least_x(multi_shop(num=2, level=4, tier=3, sims=10000), True, 3)

##Scenario 1b

Lets say you waited until you are level 4 for better odds of rolling 2-cost units. Consequently, other players have bought some 2-cost units. Particularly they have bought 10 2-cost units that you don’t want and 2 of the unit that you do want. What is the probability of getting a 2-star 2-costs unit in 3 shops?

at_least_x(multi_shop(num=3, level=3, tier=2, taken1=2, taken2=10, sims=10000), True, 3)
0.0022

How about we try and get a clearer picture of how many of your desired 2-cost units you are seeing in each shop. What is the expected value of the unit seen in these 3 shops at for the level 3 scenario? The level 4?

average(multi_shop(num=3, level=3, tier=2, sims=10000), True)
0.2857
average(multi_shop(num=3, level=3, tier=2, taken1=2, taken2=10, sims=10000), True)
0.2799

##Scenario 1 Conclusions

I have chosen to look at these situations because TFT is split into an early and late game. Depending on what part of the game you are in, you need to look for different units in the shop. I wanted to know what my chances are of finding what I want in during both sections of the game.

In the early part of the game when you are low level and not many units are taken out of the pool, you have the best chances of finding a singular 3-cost unit that you want rather than getting a 2-star 2-cost unit that you want. In both cases, rolling at level 3 and waiting to roll at level 4, the probability of 2-staring a 2-cost unit is approximetly 2%. Meanwhile, finding just one 3-cost unit only happens approximetly 11% of the time. However, you would want to stop at a 1-star since 3-staring the unit will only happen 0.2% of the time.

Scenario 2

##Scenario 2a

Now lets look at the some late game rolling odds. When looking for a 4-cost unit, the main powerhouse of your team, some people roll at level 7 while others wait to roll at level 8.

At level 7, you will have more money, allowing you to see more shops. The drawback is you have lower odds of finding 4-cost units. What is the probability of finding a 2-star 4-cost in 10 rolls, assuming 1 of your desired unit is taken and 3 other 4-costs are taken?

at_least_x(multi_shop(num=10, level=7, tier=4, taken1=1, taken2=3, sims=10000), True, 3)
0.0194

How about at level 8? Now you can only roll 6 times and 2 of your desired unit are taken and 6 other 4-costs are taken.

at_least_x(multi_shop(num=6, level=8, tier=4, taken1=2, taken2=6, sims=10000), True, 3)
0.017

Look at that! You got lucky and found 1 of your desired 4-cost units without having to roll at all. What are your odds in the level 7 case? The level 8?

at_least_x(multi_shop(num=10, level=7, tier=4, taken1=2, taken2=3, sims=10000), True, 2)
0.1123
at_least_x(multi_shop(num=6, level=8, tier=4, taken1=3, taken2=6, sims=10000), True, 2)
0.0981

Scenario 2b

The most powerful units in the game are the 5-cost units. You hope to get 1 while you are level 8 since it is rare anyone makes it to level 9. Just how lucky are those who get early 5-cost units?

What is the probability of getting any 5-cost unit at level 7 (yes, this IS possible)?

shop_sims = spot((.19, .30, .35, .15, .01), 5, 10000)

count = 0
for shop_instance in shop_sims:
  if 5 in shop_instance:
    count += 1
print(count / 10000)
0.0494

What is the probability of getting any 5-cost unit at level 8? A specific 5 cost unit?

shop_sims = spot((.15, .20, .35, .25, .05), 5, 10000)

count = 0
for shop_instance in shop_sims:
  if 5 in shop_instance:
    count += 1
print(count / 10000)
0.2367
at_least_x(multi_shop(1, level=8, tier=5, sims=10000), True)
0.0322

What is the probability of getting any 5-cost unit at level 9? A specific 5-cost unit? A specific 2-star 5-cost unit?

shop_sims = spot((.10, .15, .30, .30, .15), 5, 10000)

count = 0
for shop_instance in shop_sims:
  if 5 in shop_instance:
    count += 1
print(count / 10000)
0.5618
at_least_x(multi_shop(1, level=9, tier=5, sims=10000), True)
0.0915
at_least_x(multi_shop(1, level=9, tier=5, sims=10000), True, 3)
0.0001

##Scenario 2 Conclusions

I have chosen to look at these situations because TFT is split into an early and late game. Depending on what part of the game you are in, you need to look for different units in the shop. I wanted to know what my chances are of finding what I want in during both sections of the game.

In the late game it is better to roll for a 4-cost unit early at level 7 when you can afford more rolls and less 4-cost units are taken out of the pool than waiting to roll at level 8. Rolling any 5-cost unit at level 7 has a slightly lower probability than I expected at roughly 4.8% per shop. However, at level 8 the probability of seeing any 5-cost unit in one shop is higher than I expected at 22%.

#Scenario 3

How does the probability of seeing at least one of your desired unit in 1 shop change depending on your level? For simplicity, we will assume no units are removed from the pool.

for i in range(1, 6):
  shops = [multi_shop(num=1, level=n, tier=i, sims=10000) for n in range(1, 10)]
  results = [at_least_x(n, True) for n in shops]
  plt.plot(results)
plt.legend(['1-Cost Unit', '2-Cost Unit', '3-Cost Unit', '4-Cost Unit', '5-Cost Unit'])
plt.xlabel('Player Level (add 1 to each number for the level)')
plt.ylabel('Probability of at least 1')
plt.title('Probability of at Least One X-Cost Unit')
Text(0.5, 1.0, 'Probability of at Least One X-Cost Unit')

What about the probability of seeing at least one desired unit in X amount of shops? We will look at level 7 since this is the first level all tiers of units are availible (and running this code take a long time).

for i in range(1, 6):
  shops = [multi_shop(num=n, level=7, tier=i, sims=1000) for n in range(1, 15)]
  results = [at_least_x(n, True) for n in shops]
  plt.plot(results)
plt.legend(['1-Cost Unit', '2-Cost Unit', '3-Cost Unit', '4-Cost Unit', '5-Cost Unit'])
plt.xlabel('Number of Shops (add 1 to each shop for the number of shops)')
plt.ylabel('Probability of at least 1')
plt.title('Probability of at Least One X-Cost Unit in N Shops')
Text(0.5, 1.0, 'Probability of at Least One X-Cost Unit in N Shops')

##Scenario 3 Conclusions

I constructed these graphs because I have always wondered what level is best to roll at for what tier unit as well as how many times I need to roll to find what unit I am looking for.

When looking for a specific unit, you should roll at level:

  • 2 for a 1-cost unit
  • 4 for a 2-cost unit
  • 5 for a 3-cost unit
  • 9 for a 4-cost unit
  • 9 for a 5-cost unit

Some of these levels suprised me since they do not align with which level has the highest probability of seeing that tier unit in the shop. For example, at level 4 there is a 30% chance of a shop spot to be a 2-cost unit. According to the first graph, this is the best level to roll for a specific 2-cost unit even thought at level 6 there is a 40% chance of a shop spot being a 2-cost unit.

When looking for at least one unit at level 7, to have a 50% chance of seeing the unit you should roll:

  • 10 times for a 1-cost unit
  • 7 times for a 2-cost unit
  • 5 times for a 3-cost unit
  • 12 times for a 4-cost unit
  • way too many times for a 5-cost unit

If you remember the introduction, it costs 2 gold to refresh the shop. This means it costs 10 gold to have a 50% chance of seeing a desired 3-cost unit (spending 10 gold on rolling is a reasonable amount). This is assuming no other 3-cost units are out of the pool, including the one you are looking for, so the real number of shops may be slightly more or less depending on the situation.

#Appendex

  • Unit Pool Size and Rolling Odds: https://www.esportstales.com/teamfight-tactics/champion-pool-size-and-draw-chances
  • Help describing the basics of TFT: https://mobalytics.gg/blog/tft-guide/
# Code for example in Introduction

def is_Joe(x):
    for i in range(5):
        if x[i] == 31:
            return 1
    return 0

def is_Bob(x):
    for i in range(5):
        if x[i] == 32:
            return 1
    return 0

def is_3_cost(x):
  out = 0
  for i in range(5):
      if x[i] == 31 or x[i] == 32:
          out += 1
  return out

P = BoxModel([1,2,31,32], probs=[.55, .3, .15/2,.15/2], size=5)
X = RV(P)
x = X.sim(10000)
xx = x.apply(is_Joe)
xxx = x.apply(is_Bob)
xxxx = x.apply(is_3_cost)
print(xx.sum() / 10000)
print(xxx.sum() / 10000)
print(xxxx.count_geq(1) / 10000)
xxxx.plot()
xx.plot()
0.3176
0.3201
0.5521

# This code was never used... Oh well (I couldn't bring myself to delete it)

# -------------------- Gold --------------------
class Gold:
  def __init__(self, gold, streak, level):
    self.gold = gold
    self.streak = streak

  def __repr__(self):
    return "Gold: %d\nStreak: %d" % (self.gold, self.streak)

  def add_interest(self):                                                       # Adds interest (at the end of each round)
    i = self.gold // 10
    if i <= 5:
      self.gold += i
    else:
      self.gold += 5

  def add_streak(self):
    if self.streak >= 5:                                                        # Streak gold gain (at the start of each round)
      self.gold += 3
    elif self.streak == 4:
      self.gold += 2
    elif self.streak >= 2:
      self.gold += 1

  def round(self):
    self.add_interest()
    self.gold += 5                                                              # Passive gold gain (at the start of each round)
    self.add_streak()
    self.streak += 1

  def roll(self):                                                               # It costs 2 gold to refresh the shop
    if self.gold < 1:                                                           # You can't have negative gold
      return False

    self.gold -= 2
    return True

  def buy_unit(self, tier, amount=1):                                           # The tier of a unit is also its cost
    if self.gold < tier * amount:                                               # You can't have negative gold
      return False

    self.gold -= (tier * amount)
    return True

def predicted_gold(bal, rounds):
  '''Paramaters
      bal      : current amout of gold
      rounds   : number of rounds without rolling

  Returns amount of gold you would have in "rounds" rounds.'''
  temp_bal = Gold(bal.gold, bal.streak)
  for i in range(rounds):
    temp_bal.round()
  return temp_bal