Balancing Clash Royale: Through Card vs Card Battle Simulations and
Correlation Analysis
Colab notebook link:
https://colab.research.google.com/drive/1jhrFTNHH6DRtqQYBXiIEpIBu_dO3pJvx?usp=sharing
Exercise 1:
Q: The dataset we use is old and does not include all the cards of the current Clash Royale
version. Write Python code that scrapes the current stats from a website like
https://clashroyale.fandom.com/wiki/Cards
A:
Data Scraping and Processing:
For this exercise, I have used BeautifulSoup to parse table data from a HTML website. After
that, I have created a csv file to store the updated dataset. Following that I made some
preprocessing and cleaning of the data also in preparation for the advanced exercises 2 and 3.
import csv
import requests
from bs4 import BeautifulSoup
url = 'https://clashroyale.fandom.com/wiki/Cards'
# Step 1: Fetch the webpage content
response = requests.get(url)
html_content = response.text
# Step 2: Parse the HTML content
soup = BeautifulSoup(html_content,'html.parser')
# Step 3: Find the table you want to scrape
table = soup.find('table')
# Step 4: Extract data from the table and store it in a list of lists
table_data = []
for row in table.find_all('tr'):
row_data = []
for cell in row.find_all(['th','td']):
row_data.append(cell.text.strip())
if row_data:# Exclude empty rows
table_data.append(row_data)
# Step 5: Determine the maximum number of columns
max_cols = max(len(row)for row in table_data)
1
# Step 6: Pad rows with empty values to match the length of the longest
row
for row in table_data:
row += ['']*(max_cols - len(row))
# Step 7: Write the data to a CSV file
with open('dataset_updated.csv','w',newline='')as csvfile:
writer = csv.writer(csvfile)
writer.writerows(table_data)
print("CSV file created successfully!")
data = pd.read_csv("dataset_updated.csv",sep=",")
data=data.drop(['Unnamed: 9'], axis=1)
Code: Data Scraping for Troops Table
Scraping all other Tables: Defensive Buildings, Passive Buildings, Spells
# Find all tables with class 'wikitable'
wikitables = soup.find_all('table', {'class':'wikitable'})
# Initialize DataFrames
df_troops = pd.DataFrame()
df_defensive_buildings = pd.DataFrame()
df_passive_buildings = pd.DataFrame()
df_spells = pd.DataFrame()
# Loop through each wikitable and fill the respective DataFrames
for i,table in enumerate(wikitables[:4]):
if i == 0:
df_troops = pd.read_html(str(table))[0]
elif i == 1:
df_defensive_buildings = pd.read_html(str(table))[0]
elif i == 2:
df_passive_buildings = pd.read_html(str(table))[0]
elif i == 3:
df_spells = pd.read_html(str(table))[0]
2
i
n
d
e
x
Card
Cost
Health
(+Shield)
Damage
Hit Speed
(seconds)
Damage
per
Second
Spawn/De
ath
Damage
Count
0
Archers
3.0
304
107
0.9
118
0
2
1
Archer
Queen
5.0
1,000
225
1.2
188
0
1
2
Baby
Dragon
4.0
1,152
160
1.5
107
0
1
3
Balloon
5.0
1,680
640
2
320
240
(Death)
1
4
Bandit
3.0
907
193
1
193
0
1
Table: Scraped data without processing
Exercise 2:
Q: Implement a simulation of card vs. card battles. Use the simulation to analyze intransitive
balance, i.e., "rock paper scissors" balance: Every card should win in some situations but also
have one or multiple counters. Write a script that highlights which cards violate this rule. Such a
script could be valuable for automatically testing that the balance is not broken.
A: For this exercise, at first I simulated a 1v1 card battle for every card with just keeping their
health and damage per second in count. I did not include range calculation, death damage etc.
It was two cards battling based on simply their health and damage and whichever survived the
longest against the other was the winner. If both cards got eliminated in same round, then it was
a draw. Majority of the battles ended as draws with this very simple simulation. But I improved
the simulation by including more variable and mechanics to create a more realistic version.
I started the improvement by cleaning and processing data of different columns of the table.
This led to improved results and clarity in finding the cards that violated the intransitive balance
of the game.
3
Data Cleaning & Processing:
Here is how I cleaned and processed the data from the scraped url in order to facilitate our card
vs card simulation that is realistic and close to the actual game:
1. Health (+shield): removed brackets and their contents
def process_value(value):
# Remove brackets and their content
value = re.sub(r'\(.*\)','',value)
# Remove commas
value = value.replace(',','')
# Check if division is present
if '/' in value:
numerator,denominator = value.split('/')
return float(numerator)/float(denominator)
else:
return float(value)
2. Damage per second:
a. replaced N/A values with 0
data['Damage per Second'].fillna(0,inplace=True)
b. Removed brackets and their contents
3. Range: removed non-numeric characters
def clean_and_convert_dataRange(value):
# Use regular expression to remove non-numeric characters
value = re.sub(r'[^\d.-]+','',value)
try:
return float(value)
except ValueError:
return None # Return None for non-convertible values
4. Spawn/Death Damage: removed brackets and their contents
5. Count: removed “/” and characters after it
6. Cost: replaced N/A values with 0
data['Cost'].fillna(0,inplace=True)
7. Dropping the unnamed last column
4
Battle Simulation:
I started simulating the card vs card battle in the most basic form where 2 cards attack each
other each round and whichever card stays alive for longer is determined the winner. If both
cards get eliminated in the same round then it is considered a draw.
def simulate_battle_without_range(card1,card2,battle_duration):
time = 0
while card1.is_alive() and card2.is_alive() and time <
battle_duration:
# Both cards attack during the same time interval
card1.attack(card2,time)
card2.attack(card1,time)
# Increment time
time += 1
# Determine the winner
if card1.is_alive() and not card2.is_alive():
return card1.name
elif card2.is_alive() and not card1.is_alive():
return card2.name
else:
return "It's a draw!"
Code: Battle simulation without range and death damage
I then introduced the range variables to check which card gets to attack first. If card1 has a
greater range than card2, card1 will attack card2 first and vice versa. They both attack each
other when their respective ranges are equal or smaller than the range variable in the current
round which decreases over time.
if range_b >= range_variable and range_a >= range_variable:
card2.attack(card1,time)
card1.attack(card2,time)
elif range_a >= range_variable:
card1.attack(card2,time)
elif range_b >= range_variable:
card2.attack(card1,time)
Code: Introducing range variable
5
To determine the winner more realistically, I then introduced the death damage variable when a
card gets eliminated.
# Determine the winner
if card1.is_alive() and not card2.is_alive():
card1.health -= float(deathDamage_b)
if card1.is_alive():
return card1.name
else:
return "It's a draw!"
elif card2.is_alive() and not card1.is_alive():
card2.health -= float(deathDamage_a)
if card2.is_alive():
return card2.name
else:
return "It's a draw!"
else:
return "It's a draw!"
Code: Introducing death damage variable
Card vs Card Battle Simulation Results on next page
6
Card vs Card Battle Simulation Results:
Visualizing the battle simulation results, we can identify the wins with green cells with value 1,
lose with red cells with value 0 and draws with white cells with value 0.5. From here we can
observe the patterns and easily identify the cards violating the rule of intransitive balance in the
game. I have also listed the names of the rule-violating cards in the colab notebook where cards
that have not either won or lost a single battle are listed.
Fig: card vs card battle simulation results for 1st 15 cards
Rule Violation Check:
Checking which cards violate the rule of intransitive balance is done by calculating all the wins
and losses and if any card has not either won or lost a single battle then it is either too
overpowered or underpowered, hence imbalanced.
if wins == 0or losses == 0:
return False # The card does not satisfy the rule
else:
return True # The card satisfies the rule
7
Exercise 3:
Q: Extend the benefit calculation to all card types. You may need to loop over all cards in a for
loop and do the benefit calculation differently for each type. Also, try to incorporate mechanics
like death damage and ranged attacks.
How close to 1 can you get the correlation?
A: For this exercise, our objective is to analyze the cost and benefit of each card type in order to
make sure all cards have approximately similar cost/benefit ratio and thus approximately similar
use rates. There are four main types of cards: troops, spells, buildings and tower troops.
1. Troops
A correlation of 1 would mean that the stat in question is directly proportional to cost. Correlation
of 0 indicates no relation, and negative correlations indicate inverse proportionality. Pandas
dataframe.corr() is used to find the pairwise correlation of all columns in the Pandas Dataframe
in Python. Initially, we can begin with the benefit calculation: benefit = health (+shield)
After this if we plot cost on the X axis and benefit on the Y axis, we get a correlation of: 0.6413
initially. We can improve this result by making the benefit calculation more realistic and true to
the game.
s["Benefit"]= s["Health (+Shield)"]
s.plot.scatter(x="Cost",y="Benefit")
print("Correlation:",s["Cost"].corr(s["Benefit"]))
We can diversify and possibly improve the result by introducing new variables such as count
and damage per second in the equation. And furthermore, other variables such as Range and
Spawn/ death damage can also be introduced.
s["Benefit"]=s["Count"]* s["Damage per Second"]* s["Health (+Shield)"]
We can add weights to all the variables to signify their importance compared to each other in the
equation For example:
Cost Benefit =w1 *Health+w2*Damage+w3*Hit Speed+w4*Damage per
Second+w5*Spawn/Death Damage+w6*Range+w7*Count
Adding the sum of these weights should be normalized to 1 to ensure that the overall correlation
is in the range [-1, 1]. We can start by giving each element an equal importance and then
adjusting the weights logically. So, for 5 variables the weight of each should be 0.2.
wCount = 0.15
wDPS = 0.2
8
wHealth = 0.2
wRange = 0.1
wDeathDamage = 0.15
wCost = 0.2
s=getCleanSubset(data,["Count","Damage per Second","Health
(+Shield)","Cost","Spawn/Death Damage","Range"])
s["Benefit"]= wCount* s["Count"]+ wDPS* s["Damage per Second"]+
(wHealth* s["Health (+Shield)"]/ wCost*s["Cost"]) + wRange* s["Range"]+
wDeathDamage* s["Spawn/Death Damage"]
print("Correlation:",s["Cost"].corr(s["Benefit"]))
Code: Weighted variables in the Troops cost-benefit correlation calculation
We can immediately observe that tweaking the weights impacts and improves the result to
Correlation: 0.7155. Here I have used the weights, wCount= 0.15, wDPS= 0.2, wHealth= 0.2,
wRange= 0.1, and wDeathDamage= 0.15, wCost= 0.2.
Correlation: 0.7155
2. Spells
Spells have properties such as damage, crown tower damage and radius. My proposed
equation for the cost-benefit calculation of these cards would be:
wDamage = 0.2
wCrownTowerDamage = 0.4
wRadius = 0.2
wCost = 0.2
s=getCleanSubset(df_spells,["Damage","Crown Tower Damage",
"Radius","Cost"])
s["Benefit"]= wDamage* s["Damage"]+ wCrownTowerDamage* s["Crown Tower
Damage"]+ wRadius* s["Radius"]+ wCost*s["Cost"]
print("Correlation:",s["Cost"].corr(s["Benefit"]))
Code: Spells cost-benefit correlation calculation
Correlation: 0.87486
3. Passive Buildings
Building cards have similar properties to the troops. They have “lifetime” in addition to the
existing properties of the troop.
wLifetime = 0.25
wHealth = 0.4
wCost = 0.35
9
s=getCleanSubset(df_passive_buildings,["Lifetime","Health","Cost"])
s["Benefit"]= wLifetime* s["Lifetime"]+(wHealth* s["Health"]/
wCost*s["Cost"])
print("Correlation:",s["Cost"].corr(s["Benefit"]))
Code: Passive Buildings cost-benefit correlation calculation
Correlation: 0.85707
4. Defensive Buildings
wDPS = 0.2
wHealth = 0.3
wRange = 0.1
wLifetime = 0.15
wCost = 0.25
s=getCleanSubset(df_defensive_buildings,["Damage per Second",
"Health","Cost","Range","Lifetime"])
s["Benefit"]= wDPS* s["Damage per Second"]+(wHealth* s["Health"]/
wCost*s["Cost"]) + wRange* s["Range"]+ wLifetime* s["Lifetime"]
print("Correlation:",s["Cost"].corr(s["Benefit"]))
Code: Defensive Buildings cost-benefit correlation calculation
Correlation: 0.80882
Conclusion:
Finally, reflecting on this assignment and the results I was able to achieve I can conclude that I
have successfully analyzed the balance of Clash Royale both from a designers’ and players’
point of view. I know the game good enough to understand all the different cards and their
abilities. That is why it was possible for me to simulate the battles and calculate the cost-benefit
correlations more realistically.
The results are satisfactory to me and I believe that they make sense. Even though they could
possibly be further improved by more reasonable data processing or by introducing additive or
subtractive changes to the benefit calculation equations and reasoning. Overall, I have really
enjoyed and learned a lot about game analysis, balancing, design, data processing and
understanding python better through working on this assignment. It has been a great learning
experience.
10