In my spare time, I like to play tabletop RPGs akin to Dungeons and Dragons. Most recently I started DM-ing my first Call of Cthulhu games. Anyone that has tried DM-ing knows that it is harder than it looks. A reoccurring fear of mine is that my players are either under or over leveled, making their encounters either too hard or a walk in the park. To fix this, I decided to make tabletop RPGs somehow even nerdier by analyzing my player’s character’s level with python.

For this project, I am using:

  • python - quick, easy, good for analyzing data.
    • pandas - data science library
    • plotly - for making pretty, interactable graphs

Base Stats for Characters

The first step is to collect and clean the data. I recently made the switch to the online tabletop FoundryVTT to host my games, which comes with a ton of great features that other, more popular tabletops (cough, cough Roll20) does not have, such as character exporting. A FoundryVTT exported character is a simple .json file, but that is good enough for me. Here is my initial import and normalization of the file in python:

import json
import pandas as pd
from plotly.io import write_image
pd.options.plotting.backend = "plotly"

character_names = [
    "carolina-reaper",
    "jack-hammer",
    "pinky-poundtown"
]

characters = []

# Import and clean the data
for character in character_names:
  with open(f"characters/{character}.json", "r") as f:
    data = json.load(f)
    characters.append(data)

df = pd.json_normalize(characters)

Next step is to walk through the data. The first comparison I wanted to make was the player’s attributes. Their attributes are the “big” characteristics that come to mind when thinking of an RPG: strength, constitution, size, dexterity, appearance, intelligence, power, and education. These skills are randomly rolled by the player during character creation.

Since there where only eight attributes all together, I hardcoded their respective location in the .json to a dict:

base_stats = {'STR': 'system.characteristics.str.value',
              'CON': 'system.characteristics.con.value',
              'SIZ': 'system.characteristics.siz.value',
              'DEX': 'system.characteristics.dex.value',
              'APP': 'system.characteristics.app.value',
              'INT': 'system.characteristics.int.value',
              'POW': 'system.characteristics.pow.value',
              'EDU': 'system.characteristics.edu.value'}

base_stats_df = df[base_stats.values()].copy()

base_stats_df = base_stats_df.rename(
    columns={v: k for k, v in base_stats.items()})

base_stats_df.index = character_names

print(base_stats_df)

The Graphs

Now we can get our first graph, comparing the base attributes (I called the characteristics) of the characters:

fig = base_stats_df.plot.bar(title='Base Stats for Characters (Total)')
fig.show( )

By grouping the attributes together, I can get a better breakdown of each stat:

fig = base_stats_df.plot.bar(title='Base Stats for Characters', barmode='group')
fig.show( )

Looks like a character, Pinky Poundtown, is higher level than the others, and character Carolina Reaper is slightly below average, especially their DEX. Before asking the players to change around their attibutes, let’s see if their occupatioal and personal skills help close the gap.

Skills for Characters

There are a lot of skills in Call of Cthulhu – more so than many other tabletop RPGs. These include everything from accounting to firearms to electrical repair. Character creation starts with players allocating points to skills based on their profession, sort of like a class in other roleplaying games. After that, the player gets to allocate another amount of points for their personal development. All this to say the player has more of a decision as to where their points are placed, unlike the attributes.

By looping through the original dataframe we can extract the skills for each player.

skill_df = pd.DataFrame()
for index, row in df.iterrows():
  for i in row['items']:
    if i['type'] == 'skill':
      # if its not in the dataframe, add it
      if i['name'] not in skill_df.columns:
        skill_df[i['name']] = int(i['system']['base'])
      
      base = int(i['system']['base'])
      
      # Add occupation skills (if any)
      occupation = i['system']['adjustments']['occupation']
      if occupation:
        base += int(occupation)
      
      # Add personal skills (if any)
      personal = i['system']['adjustments']['personal']
      if personal:
        base += int(personal)
      skill_df.at[index, i['name']] = base
skill_df.index = character_names

The Graphs

Once I had the data the way I wanted, it was as simple as reusing the plot from the previous attributes:

fig = skill_df.plot.bar(title='Skills for Characters (Total)')
fig.show( )

(Apologies that the graph is hard to read, there are a lot of skills).

Again, I grouped these to get a better idea of the differences:

fig = skill_df.plot.bar( title='Skills for Characters', barmode='group')
fig.show( )

Its hard to come to a conclusions, besides that Pinky Poundtown might be a little bit better off than the others, but this is all relative to the skill.

Filtered Skills for Characters

One way to simplify this is to only use the skills that the players put points into, either occupational or personal, and skip over the other skills. Unfortunately, this couldn’t work exactly as I hoped. During the character creation process, some of my players changed their base value rather than their occupation or personal value. I tried to remedy this by checking if base was over a threshold of 25 – higher than most base values but not too high.

skill_filtered_df = pd.DataFrame()
for index, row in df.iterrows():
  for i in row['items']:
    if i['type'] == 'skill':
      # if its not in the dataframe, add it
      occupation = i['system']['adjustments']['occupation']
      personal = i['system']['adjustments']['personal']
      
      if i['name'] not in skill_filtered_df.columns and (occupation or personal):
        skill_filtered_df[i['name']] = int(i['system']['base'])
      
      base = int(i['system']['base'])
      if (occupation or personal or base > 25):

      
        if occupation:
          base += int(occupation)
      
        if personal:
          base += int(personal)

        skill_filtered_df.at[index, i['name']] = base

      else:
        skill_filtered_df.at[index, i['name']] = 0
        
skill_filtered_df.index = character_names

The Graphs

I’ll cut to the chase with these:

fig = skill_filtered_df.plot.bar(title='Skills for Characters (Filtered)')
fig.show( )
fig = skill_filtered_df.plot.bar( title='Skills for Characters', barmode='group')
fig.show( )

Conclusion

Overall, what did I learn? Well this was a good refresher on my college statistics class. The player playing Pinky might be slightly over leveled, but I’m sure the player won’t take abuse their advantage. Plus, everyone being exactly equal doesn’t sound exciting either. And at the end of the day, its the players entertainment I care more about than a perfectly balanced game.

Other than that there isn’t much to gather from this data. I had fun collecting and analyzing it, and what better way is there to spend a weekday night?

I may continue this and track the player’s progression as they level, I think that could be an interesting project – so stay tuned for part two!