I am a big fan of optimization. This extends into many aspects of my life, including recreation. Because of this, I am big fan of min-maxing in video games, and one of my favorites is a game called Crusader Kings III.
In CK3 you control a dynasty throughout the Middle Ages, playing as your ruler until they die, and then continuing as their heir. There is no real goal, besides making sure you always have an heir. Despite not having an explicit goal, generally you want a strong military to support both the defense of your realm and the conquest of others.
In CK3 there are two main types of military, levies and Man-at-Arms (MaA). Levies are essentially peasants drafted to fight, whereas MaA are trained soldiers that cost gold to both recruit and maintain, even when unraised. Basically, levies are cannon fodder and MaAs are what win you battles. There are also seige MaA, but they arent used in battle and therefore out of the scope of this analysis.
There are 6 main types of damaging MaA,which are:
each with their own strengths and weaknesses. There is a countering system ( Light Infantry > Heavy Infantry > Pikemen > Horsemen > Archer > Light Infantry) but countering becomes less effective as the number of one type of unit increases (i.e. 100 archers effectively counter 100 light infantry, but 100 archers fail to counter 1,000 light infantry.) This drop-off in countering effectiveness means when facing the game's AI you can effectively build only one type of unit and be safe from countering.
Each MaA also potentially has terrain bonuses or weaknesses (ex. horsemen generally perform worse in mountainous terrain). This generally is only important if your domain is located in an area that is predominantly one type of terrain. Otherwise, general performance is more important.
Each MaA has a unit size, which is 100 soldiers per unit for all but a few types of MaA (Heavy Calavry, Elephant Cavalry, and a few other outliers).
Finally, and most importantly, each MaA has 4 battle stats and a cost and upkeep:
Each MaA has different values for each of these stats, and these are what make the real difference in a battle.
There are 38 MaAs that are tied to different cultural traditions in the game. The goal of this analysis is to figure out which is the best, to guide the player in wisely choosing these traditions to grant access to the best MaA.
First we need to get the data for all the MaAs we are interested in comparing. This data can be conveniently found on the CK3 wiki. Let's grab the data using pandas. We use selenium since the webpage will throw errors with javascript disabled.
import time
from io import StringIO
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import pandas as pd
from IPython.display import display, HTML
import rich
# Display helper function to hide index
def show_df(df):
display(HTML(df.to_html(index=False)))
# Get dataframe from the webpage
options = Options()
driver = webdriver.Chrome(options=options)
url = "https://ck3.paradoxwikis.com/Army"
driver.get(url)
html = driver.page_source
driver.quit()
df_list = pd.read_html(StringIO(html))
df = df_list[2] # We want the third table on the page
df.style.hide(axis=0)
show_df(df[:10])
Regiment | Type | Cost | Raised maintenance | Provisions used | Unnamed: 5 | Unnamed: 6 | Unnamed: 7 | Unnamed: 8 | Counters | Favorable terrain | Unfavorable terrain | Tradition | Era |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Abudrar | NaN | 81.0 | 0.81 | 3 | 20 | 16 | 20 | 10 | NaN | NaN | NaN | Mountain Herding | Tribal |
Ayyar | NaN | 75.0 | 1.08 | 12 | 35 | 22 | 12 | 0 | NaN | NaN | NaN | Futuwaa | Tribal |
Bush Hunters | NaN | 55.0 | 0.66 | 3 | 30 | 12 | 5 | 0 | NaN | NaN | NaN | Bush Hunting | Tribal |
Druzhina | NaN | 117.0 | 1.56 | 7 | 40 | 30 | 0 | 30 | NaN | NaN | NaN | Druzhina | Tribal |
Gendarmes | NaN | 240.0 | 2.52 | 21 | 125 | 40 | 20 | 10 | NaN | NaN | NaN | Chanson de Geste | Late Medieval |
Goedendag Militia | NaN | 45.0 | 0.45 | 3 | 13 | 18 | 0 | 16 | NaN | NaN | NaN | Polders | Late Medieval |
Guinean Uplanders | NaN | 54.0 | 0.54 | 3 | 18 | 14 | 0 | 20 | NaN | NaN | NaN | Upland Skirmishing | Tribal |
Horn Warriors | NaN | 45.0 | 0.45 | 7 | 12 | 16 | 0 | 20 | NaN | NaN | NaN | Mountain Skirmishing | Tribal |
Horse Archers | NaN | 135.0 | 1.35 | 7 | 45 | 20 | 40 | 30 | NaN | NaN | NaN | Horse Lords | Tribal |
Huscarls | NaN | 115.0 | 1.53 | 3 | 40 | 26 | 0 | 24 | NaN | NaN | NaN | Hirds / Coastal Warriors | Tribal |
Now that we have the data, we have to clean up a couple of things. First let's add the proper column names.
df.rename(columns={"Unnamed: 5": "Damage", "Unnamed: 6" : "Toughness", "Unnamed: 7" : "Pursuit", "Unnamed: 8" : "Screen"}, inplace=True)
df.columns
Index(['Regiment', 'Type', 'Cost', 'Raised maintenance', 'Provisions used', 'Damage', 'Toughness', 'Pursuit', 'Screen', 'Counters', 'Favorable terrain', 'Unfavorable terrain', 'Tradition', 'Era'], dtype='object')
Now let's fix the NaN values. For the purposes of this analysis we do not care about counters, favorable terrain, or unfavorable terrain, so we can drop those columns. However, we need the unit type for the unit size. To do this I will just manually input the unit sizes in the Type column.
# drop NaN columns we don't need
df.drop(columns=["Counters", "Favorable terrain", "Unfavorable terrain"], inplace=True)
unit_types = {
"Abudrar": 'light infantry',
"Akritai": 'heavy infantry',
"Ayrudzi": 'light cavalry',
"Ayyar": 'heavy infantry',
"Ballistrai": 'archers',
"Bondi": 'pikemen',
"Bush Hunters": 'archers',
"Conrois": 'heavy cavalry',
"Druzhina": 'heavy infantry',
"Gendarmes": 'heavy cavalry',
"Goedendag Militia": 'light infantry',
"Guinean Uplanders": 'light infantry',
"Horn Warriors": 'light infantry',
"Horse Archers": 'horse archers',
"Huscarls": 'heavy infantry',
"Kataphraktoi": 'heavy cavalry',
"Khandayat": 'heavy infantry',
"Konni": 'light cavalry',
"Lenkas": 'heavy infantry',
"Longbowmen": 'archers',
"Metsänvartija": 'archers',
"Monaspa": 'heavy cavalry',
"Mountaineers": 'heavy infantry',
"Mubarizun": 'heavy infantry',
"Mulathanūm": 'light cavalry',
"Nile Archers": 'archers',
"Palace Guards": 'heavy infantry',
"Picchieri": 'pikemen',
"Sarawit": 'heavy infantry',
"Schiltron": 'pikemen',
"Shomer": 'light infantry',
"Skoutatoi": 'pikemen',
"Tarkhans": 'heavy cavalry',
"Tawashi": 'light cavalry',
"Varangian Veterans": 'heavy infantry',
"Vigmen": 'archers',
"Zbrojnosh": 'heavy infantry',
"Zupin Spearmen": 'pikemen'
}
unit_sizes = {
"Abudrar": 100,
"Akritai": 100,
"Ayrudzi": 50,
"Ayyar": 50,
"Ballistrai": 50,
"Bondi": 100,
"Bush Hunters": 100,
"Conrois": 50,
"Druzhina": 100,
"Gendarmes": 50,
"Goedendag Militia": 100,
"Guinean Uplanders": 100,
"Horn Warriors": 100,
"Horse Archers": 100,
"Huscarls": 100,
"Kataphraktoi": 50,
"Khandayat": 100,
"Konni": 100,
"Lenkas": 100,
"Longbowmen": 100,
"Metsänvartija": 100,
"Monaspa": 50,
"Mountaineers": 100,
"Mubarizun": 100,
"Mulathanūm": 100,
"Nile Archers": 100,
"Palace Guards": 100,
"Picchieri": 100,
"Sarawit": 100,
"Schiltron": 100,
"Shomer": 100,
"Skoutatoi": 100,
"Tarkhans": 50,
"Tawashi": 100,
"Varangian Veterans": 100,
"Vigmen": 100,
"Zbrojnosh": 100,
"Zupin Spearmen": 100
}
df['Type'] = df['Regiment'].map(unit_types)
df['Size'] = df['Regiment'].map(unit_sizes)
show_df(df[:10])
Regiment | Type | Cost | Raised maintenance | Provisions used | Damage | Toughness | Pursuit | Screen | Tradition | Era | Size |
---|---|---|---|---|---|---|---|---|---|---|---|
Abudrar | light infantry | 81.0 | 0.81 | 3 | 20 | 16 | 20 | 10 | Mountain Herding | Tribal | 100 |
Ayyar | heavy infantry | 75.0 | 1.08 | 12 | 35 | 22 | 12 | 0 | Futuwaa | Tribal | 50 |
Bush Hunters | archers | 55.0 | 0.66 | 3 | 30 | 12 | 5 | 0 | Bush Hunting | Tribal | 100 |
Druzhina | heavy infantry | 117.0 | 1.56 | 7 | 40 | 30 | 0 | 30 | Druzhina | Tribal | 100 |
Gendarmes | heavy cavalry | 240.0 | 2.52 | 21 | 125 | 40 | 20 | 10 | Chanson de Geste | Late Medieval | 50 |
Goedendag Militia | light infantry | 45.0 | 0.45 | 3 | 13 | 18 | 0 | 16 | Polders | Late Medieval | 100 |
Guinean Uplanders | light infantry | 54.0 | 0.54 | 3 | 18 | 14 | 0 | 20 | Upland Skirmishing | Tribal | 100 |
Horn Warriors | light infantry | 45.0 | 0.45 | 7 | 12 | 16 | 0 | 20 | Mountain Skirmishing | Tribal | 100 |
Horse Archers | horse archers | 135.0 | 1.35 | 7 | 45 | 20 | 40 | 30 | Horse Lords | Tribal | 100 |
Huscarls | heavy infantry | 115.0 | 1.53 | 3 | 40 | 26 | 0 | 24 | Hirds / Coastal Warriors | Tribal | 100 |
Now that the data clean we can get to the analysis. Firstly, let's look at what the most effective MaA is for winning battles with no consideration for maintenance or what happens in the retreat phase. There are a few things we must first note:
These will come in handy later, but initially let's just look at damage * toughness * unit size
Since some MaAs only become available much later into the game, they have zero utility for most of a play-through. Because of this I will limit the comparisons to only tribal era MaAs, which are always available to be recruited.
df = df[df.Era == 'Tribal'].copy()
df['Era'].value_counts()
Era Tribal 33 Name: count, dtype: int64
df['d*t*s'] = df['Damage'] * df['Toughness'] * df['Size']
df.sort_values(by='d*t*s', ascending=False, inplace=True)
show_df(df.loc[:,['Regiment', 'Type', 'd*t*s']][:10])
Regiment | Type | d*t*s |
---|---|---|
Kataphraktoi | heavy cavalry | 210000 |
Tarkhans | heavy cavalry | 165000 |
Monaspa | heavy cavalry | 165000 |
Varangian Veterans | heavy infantry | 135000 |
Conrois | heavy cavalry | 125000 |
Druzhina | heavy infantry | 120000 |
Mubarizun | heavy infantry | 112500 |
Mountaineers | heavy infantry | 104000 |
Huscarls | heavy infantry | 104000 |
Lenkas | heavy infantry | 92400 |
Well, there's the top 10, but let's see if we can get a better sense of the distribution.
import plotly.express as px
from plotly.colors import named_colorscales
fig = px.strip(
df,
y="d*t*s",
hover_name='Regiment',
hover_data=['Type', 'Size'],
labels={'d*t*s': 'Damage x Toughness x Size'},
title="Most Effective MaA in CK3",
height=600,
width=800,
template='plotly_dark',
color='Type'
)
# fig.update_traces(marker_color="yellow")
fig.show()
We can see that the top contenders are all heavy cavalry, even before taking into account the combat width advantage they have for having a smaller unit size.
This shows Kataphraktoi as our top performer by Damage x Toughness x Size.
There are some instances, however, where Toughness or Damage may be more or less preferable than the other. For example, if you are running an army with a lot of levies (cannon fodder), toughness is less important since more of the enemy's damage is soaked up by your levies. Conversely, if you are running a Knight heavy army (Knights are special characters, not nameless troops), toughness may be more important to protect your relatively fragile but high damage knights. Let's look at a scatter plot comparing damage and toughness.
import plotly.graph_objects as go
df['t*s'] = df['Toughness'] * df['Size']
df['d*s'] = df['Damage'] * df['Size']
fig = px.scatter(
df,
x='d*s',
y='t*s',
labels={'d*s':"Damage x Size", 't*s': 'Toughness x Size'},
title='Damage vs. Toughness Trade-off',
hover_name='Regiment',
color='Type',
height=600,
width=800,
template='plotly_dark')
fig.add_shape(
type='line',
xref='x',
yref='y',
x0=0,
y0=0,
x1=df['d*s'].max()*1.1,
y1=df['t*s'].max()*1.1,
opacity=0.5
)
fig.show()
This paints a slightly more nuanced picture. Now it seems like there are two main contenders for "best" depending on what the player is prioritizing. This now shows Varangian Veterans as the best if the player prefers toughness, and Kataphraktoi if the player prefers damage, based on the rest of their army composition.
Pursuit is how much extra damage can be done after winning a battle. This can be useful for what is called "stack-wiping," which is when you kill or capture the entire enemy army, with no one able to escape. While this can be very useful it requires winning the battle first. Therefore, I will not be considering it in these analyses. Similarly, screen is only useful when you have lost a battle to protect from further loses. Our goal is to not lose any battles, so we will not be considering it in these analyses either.
MaA cost gold to recruit, and to maintain. The cost to recruit is a one time cost, and only really matter in the very early game, since once recruited you will never have to pay that cost again. For this reason, we will not consider it in these analyses. Maintenance, however, is important for much longer. There are severe penalties for fighting battles while in debt, and a massive army of the most expensive troops can lead a player to financial ruin very quickly. In the late game maintenance is less of a concern since your economy should be strong enough to support any army, but for much of the game this is still an important consideration. Lets look at the same two graphs as before, but now weighted by maintenance costs.
df['d*t*s/gold'] = df['d*t*s'] / df['Raised maintenance']
df.sort_values(by='d*t*s/gold', ascending=False, inplace=True)
show_df(df.loc[:,['Regiment', 'Type', 'Raised maintenance','d*t*s/gold', 'Era']][:10])
Regiment | Type | Raised maintenance | d*t*s/gold | Era |
---|---|---|---|---|
Mubarizun | heavy infantry | 1.44 | 78125.000000 | Tribal |
Kataphraktoi | heavy cavalry | 2.73 | 76923.076923 | Tribal |
Druzhina | heavy infantry | 1.56 | 76923.076923 | Tribal |
Mountaineers | heavy infantry | 1.44 | 72222.222222 | Tribal |
Ayrudzi | light cavalry | 1.05 | 71428.571429 | Tribal |
Conrois | heavy cavalry | 1.78 | 70224.719101 | Tribal |
Huscarls | heavy infantry | 1.53 | 67973.856209 | Tribal |
Varangian Veterans | heavy infantry | 2.00 | 67500.000000 | Tribal |
Horse Archers | horse archers | 1.35 | 66666.666667 | Tribal |
Khandayat | heavy infantry | 1.44 | 64166.666667 | Tribal |
This changes up the top 10 quite a bit. With cost considerations heavy infantry MaAs are now preferable, with Mubarizun coming out on top. Let's look at the strip chart.
fig = px.strip(
df,
y="d*t*s/gold",
hover_name='Regiment',
hover_data=['Type', 'Size', 'Raised maintenance'],
labels={'d*t*s/gold': '(Damage x Toughness x Size) / Maintenance Cost'},
title="Most Effective MaA in CK3 Per Gold",
height=600,
width=800,
template='plotly_dark',
color='Type'
)
# fig.update_traces(marker_color="yellow")
fig.show()
We can see that the ranking are much more linear. There are some interesting outliers. For one, almost all the light cavalry is terrible, except the Ayrudzi. This is likely a balancing effort by the developers of the game to make light cavalry more attractive, since Ayrudzi are a recent addition. Another outlier is longbowmen, which are the lowest of the bunch. The reason for this is, unlike other MaAs, they improve with each era, becoming very formidable by the end of the game. Unfortunately as mentioned before, late game has limited utility since the player will likely already be very strong, and can field almost any force.
Now finally lets look at Damage and Toughness individually, while considering Maintenance.
df['t*s/gold'] = df['t*s'] / df['Raised maintenance']
df['d*s/gold'] = df['d*s'] / df['Raised maintenance']
fig = px.scatter(
df,
x='d*s/gold',
y='t*s/gold',
labels={'d*s/gold':"Damage x Size / Maintenance Cost", 't*s/gold': 'Toughness x Size / Maintenance Cost'},
title='Damage vs. Toughness Trade-off per Gold',
hover_name='Regiment',
color='Type',
height=600,
width=800,
template='plotly_dark')
fig.add_shape(
type='line',
xref='x',
yref='y',
x0=0,
y0=0,
x1=df['d*s/gold'].max()*1.1,
y1=df['t*s/gold'].max()*1.1,
opacity=0.5
)
fig.show()
This paints an interesting picture. Our usual suspects (Heavy Cavalry and Heavy Infantry) aren't found on either extreme. Instead, we have two archers on the far right (Damage heavy per maintenance) and a single light infantry on the top (Toughness heavy per maintenance). None of the specialist appear in the top 10 damage x toughness charts, but may prove valuable if the player:
However, situation 1 and situation 2 very rarely happen together, since if you have a lot of levies or a lot of strong knights, you likely will have a lot of income too.
So, what does all of this mean? Essentially, it can be boiled down to these questions.
I plan to come back to this and compute the optimal MaA for adventurers (who use provisions for maintenance, not gold), as well as to add a head-to-head simulation to better capture the effects of terrain width. Taking terrain width into account will boost up heavy cavalry more, and perhaps make them the best choice given a budget as well. Additionally, it would be interesting to do a pairwise 1v1 tournament to see who has the most wins and loses when considering unit type counters.