Crusader King III Man-at-Arms analysis

About Crusader Kings

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.

Military Overview

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:

  • Light Infantry
  • Heavy Infantry
  • Pikemen
  • Light Horsemen
  • Heavy Horsemen
  • Archers

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:

  • Damage: How much damage a soldier deals during the battle phase
  • Toughness: How much damage a soldier can take before dying/retreating
  • Pursuit: How much damage a soldier deals against retreating enemies (retreat phase)
  • Screen: How much damage a soldier can block while retreating (retreat phase)
  • Cost: How much gold it takes to train one unit of the MaA
  • Upkeep: How much gold it takes to maintain the unit (raised and unraised)

Each MaA has different values for each of these stats, and these are what make the real difference in a battle.

The Problem

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.

Getting started

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])
RegimentTypeCostRaised maintenanceProvisions usedUnnamed: 5Unnamed: 6Unnamed: 7Unnamed: 8CountersFavorable terrainUnfavorable terrainTraditionEra
AbudrarNaN81.00.81320162010NaNNaNNaNMountain HerdingTribal
AyyarNaN75.01.08123522120NaNNaNNaNFutuwaaTribal
Bush HuntersNaN55.00.663301250NaNNaNNaNBush HuntingTribal
DruzhinaNaN117.01.5674030030NaNNaNNaNDruzhinaTribal
GendarmesNaN240.02.5221125402010NaNNaNNaNChanson de GesteLate Medieval
Goedendag MilitiaNaN45.00.4531318016NaNNaNNaNPoldersLate Medieval
Guinean UplandersNaN54.00.5431814020NaNNaNNaNUpland SkirmishingTribal
Horn WarriorsNaN45.00.4571216020NaNNaNNaNMountain SkirmishingTribal
Horse ArchersNaN135.01.35745204030NaNNaNNaNHorse LordsTribal
HuscarlsNaN115.01.5334026024NaNNaNNaNHirds / Coastal WarriorsTribal

Cleaning

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])
RegimentTypeCostRaised maintenanceProvisions usedDamageToughnessPursuitScreenTraditionEraSize
Abudrarlight infantry81.00.81320162010Mountain HerdingTribal100
Ayyarheavy infantry75.01.08123522120FutuwaaTribal50
Bush Huntersarchers55.00.663301250Bush HuntingTribal100
Druzhinaheavy infantry117.01.5674030030DruzhinaTribal100
Gendarmesheavy cavalry240.02.5221125402010Chanson de GesteLate Medieval50
Goedendag Militialight infantry45.00.4531318016PoldersLate Medieval100
Guinean Uplanderslight infantry54.00.5431814020Upland SkirmishingTribal100
Horn Warriorslight infantry45.00.4571216020Mountain SkirmishingTribal100
Horse Archershorse archers135.01.35745204030Horse LordsTribal100
Huscarlsheavy infantry115.01.5334026024Hirds / Coastal WarriorsTribal100

Analysis

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:

  • Damage: During a battle, there are several rounds until one side is entirely routed. During each of these round, 1 soldier does 0.03 damage per point of Damage stat.
  • Toughness: Each point allows for a soldier to take 1 total damage before routing.
  • Combat Width: An extra consideration is something called combat width, computed with the equation (Enemy units + Ally units) * (0.5 * Terrain Width). This gives an advantage to smaller unit sizes in a head to head. For the purposes of this analysis we will assume the terrain width is 1.

These will come in handy later, but initially let's just look at damage * toughness * unit size

Era considerations

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

Damage x Toughness x Size

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])
RegimentTyped*t*s
Kataphraktoiheavy cavalry210000
Tarkhansheavy cavalry165000
Monaspaheavy cavalry165000
Varangian Veteransheavy infantry135000
Conroisheavy cavalry125000
Druzhinaheavy infantry120000
Mubarizunheavy infantry112500
Mountaineersheavy infantry104000
Huscarlsheavy infantry104000
Lenkasheavy infantry92400

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.

Damage x Size vs. 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.

On Pursuit and Screen

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.

Gold Considerations

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])
RegimentTypeRaised maintenanced*t*s/goldEra
Mubarizunheavy infantry1.4478125.000000Tribal
Kataphraktoiheavy cavalry2.7376923.076923Tribal
Druzhinaheavy infantry1.5676923.076923Tribal
Mountaineersheavy infantry1.4472222.222222Tribal
Ayrudzilight cavalry1.0571428.571429Tribal
Conroisheavy cavalry1.7870224.719101Tribal
Huscarlsheavy infantry1.5367973.856209Tribal
Varangian Veteransheavy infantry2.0067500.000000Tribal
Horse Archershorse archers1.3566666.666667Tribal
Khandayatheavy infantry1.4464166.666667Tribal

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.

Damage vs Toughness per Maintenance

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:

  1. Has very little income
  2. Has a ton of levies (High damage preference) or Very strong Knights (High toughness preference)

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.

Conclusion

So, what does all of this mean? Essentially, it can be boiled down to these questions.

  1. Do you have a lot of extra income? If so max out on Kataphraktoi.
  2. Do you have a good amount of extra income, but can't support a lot of Kataphraktoi? If so max out on Mubarizun.
  3. Are you struggling with gold and can't support max stacks of Mubarizun or other comparable Heavy Infantry? If so Ayrzudi have the lowest maintenance in the Damage x Toughness / Gold top 10.
  4. Are you struggling with gold but want a damage or toughness focused composition? Max out Nile archers for damage, or Shomer for toughness.

Further work

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.