knitr::opts_chunk$set(warning = F, message = F)

This document is an exploration of the killing-blows data recorded from the Iron Gods campaign.

dat <- readr::read_csv(file.path('data', 'ig_events.csv'))
sessions <- readr::read_csv(file.path('data', 'ig_sessions.csv'))
dat <- mutate(
  dat,
  location = factor(location, levels = rev(unique(location))),
  sub_location = factor(
    sub_location, levels = c('Other', rev(unique(sub_location)))),
  chapter = as.factor(chapter),
  monster_type = factor(monster_type, levels = c('Grunt', 'Tough', 'Boss'))
)

sessions$chapter <- as.factor(sessions$chapter)
pal <- c(
  Corrin = "#F6D645FF",
  Hardad = "#4D4D4DFF",
  Nadia = "#DD513AFF",
  Telum = "#35608DFF"
)

# image(as.matrix(1:length(pal)), col = pal)

Session info

We’ve been playing for almost three years! We were pretty active in 2016, less-so in 2017. But 2018 is off to a good start.

sessions %>%
  group_by(as.factor(lubridate::year(session_date))) %>%
  summarize(n = length(unique(session_date))) %>%
  mutate(per_month = round(
    n / c(12, 12, 12, lubridate::month(Sys.Date())), 1)) %>%
  knitr::kable(col.names = c('Year', 'Number of Sessions', 'Sessions per Month'),
               format = 'html') %>%
  kableExtra::kable_styling(full_width = F)
Year Number of Sessions Sessions per Month
2015 9 0.8
2016 17 1.4
2017 11 0.9
2018 3 1.5
# Calculate gap info
sessions$gap <- as.numeric(
  sessions$session_date - 
    c(NA, sessions$session_date[1:(nrow(sessions) - 1)]))

longest <- which(sessions$gap == max(sessions$gap, na.rm = T))

The longest gap between sessions was 99 days, between Aug 09 and Nov 16, 2016.

Killing blows

Creature Type

Not all enemies are created equal. We’ve categorized the creatures we faced into three types: Grunts, Toughs, and Bosses.

dat %>% group_by(monster_type) %>% summarize(kills = sum(monster_count)) %>%
  knitr::kable(col.names = c('Creature Type', 'Count'), format = 'html') %>%
  kableExtra::kable_styling(full_width = F)
Creature Type Count
Grunt 212
Tough 125
Boss 32
NA 4

Creature Type by Geography

This can give us a rough breakdown of the kind of enemies we faced in each location. Were they fewer and tougher, or more numerous and weaker?

Location

Scrapwall had the highest proportion of Grunts, although Torch and Iadenveigh are close seconds. Smokewood stands out for its tougher enemies; the Scar of the Spider and Starfall are somewhere in the middle.

title <- 'Creature Breakdown by Location'
dat %>%
  filter(!is.na(monster_type)) %>%
  group_by(location) %>% filter(n() > 10) %>%
  mutate(monster_type = factor(
    monster_type, 
    levels = rev(levels(monster_type)))) %>%
  group_by(monster_type, location) %>%
  summarize(kills = sum(monster_count)) %>%
  group_by(location) %>%
  mutate(kills_pct = kills / sum(kills)) %>%
  ggplot(aes(x = location, y = kills_pct, fill = monster_type)) +
  geom_bar(stat = 'identity') +
  scale_y_continuous(
    labels = scales::percent, expand = c(0, 0), limits = c(0, 1.05)) +
  scale_fill_brewer('Creature Type:', palette = 7, direction = -1,
                    guide = guide_legend(reverse = T)) +
  labs(y = 'Creatures', x = '', title = title) +
  theme_blank() +
  theme(legend.position = 'top',
        axis.text.y = element_text(size = 12)) +
  coord_flip()

Specific sites

However, there is variation within locations. The Dominion Dropship, for example, has the highest proportion of Tough enemies of any major site in the game, and all of the Starfall sites outside of the Black Sovereign’s palace were tougher enemies.

title <- 'Creature Breakdown by Specific Site'
dat %>%
  filter(!is.na(monster_type)) %>%
  mutate(monster_type = factor(
    monster_type, 
    levels = rev(levels(monster_type)))) %>%
  group_by(location) %>% filter(n() > 10) %>%
  ungroup() %>%
  mutate(location = factor(location, levels = unique(location))) %>%
  # tidyr::replace_na(list(sub_location = 'Other')) %>%
  group_by(monster_type, location, sub_location) %>%
  summarize(kills = sum(monster_count)) %>%
  group_by(location, sub_location) %>%
  mutate(kills_pct = kills / sum(kills)) %>%
  ggplot(aes(x = sub_location, y = kills_pct, fill = monster_type)) +
  geom_bar(stat = 'identity') +
  coord_flip() +
  facet_grid(location ~ ., scales = 'free_y', space = 'free',
             switch = 'y') +
  scale_y_continuous(labels = scales::percent, 
                     expand = c(0, 0), limits = c(0, 1.05)) +
  scale_fill_brewer(
    '', palette = 7, direction = -1, guide = guide_legend(reverse = T)) +
  labs(y = 'Creatures', x = '', title = title) +
  theme_blank() +
  theme(legend.position = 'top',
        strip.text.y = element_text(angle = 180))

Creature Types and PCs

Number of kills by PC

Some PCs are better at dealing with certain kinds of monsters. Corrin has killed the most Bosses and Grunts, but Hardad has killed the most Toughs.

title <- 'Types of creatures killed by PCs'
dat %>%
  filter(!is.na(monster_type)) %>%
  group_by(pc, monster_type) %>%
  summarize(kills = sum(monster_count)) %>%
  ggplot(aes(x = pc, y = kills, fill = monster_type)) +
  geom_bar(stat = 'identity', position = 'dodge') +
  scale_y_continuous(expand = c(0, 0), limits = c(0, 85)) +
  scale_fill_brewer('Creature Type:', palette = 7) +
  theme_axes() + theme(axis.text.x = element_text(size = 12)) +
  labs(x = 'Player Character', y = 'Creatures Killed', title = title)

Proportion of kills by PC

As a proportion of their kills, Telum actually has the most Boss-heavy list, while Nadia is most heavily skewed towards killing Grunts.

title <- 'Proportion of PC\'s kills by type'
dat %>%
  filter(!is.na(monster_type)) %>%
  mutate(monster_type = factor(
    monster_type, 
    levels = rev(levels(monster_type)))) %>%
  group_by(pc, monster_type) %>%
  summarize(kills = sum(monster_count)) %>%
  group_by(pc) %>%
  mutate(kills_pct = kills / sum(kills)) %>%
  ggplot(aes(x = pc, y = kills_pct, fill = monster_type)) +
  geom_bar(stat = 'identity') +
  scale_y_continuous(labels = scales::percent, expand = c(0, 0), limits = c(0, 1.05)) +
  scale_fill_brewer('Creature Type:', palette = 7, direction = -1,
                    guide = guide_legend(reverse = T)) +
  theme_blank() + 
  theme(axis.text.y = element_text(size = 12),
        legend.position = 'top') +
  coord_flip() +
  labs(x = '', y = 'Creatures Killed', title = title)

Changes by PC over time

Telum’s been killing bosses this whole time, but otherwise I think the main takeaway here is that we’re coding more enemies as Toughs as we enter higher levels. That might be a tendency to code enemies that were previously Toughs as if they’re still Toughs, a bit longer than we should.

title <- 'Proportion of PC\'s kills by type over time'
dat %>%
  filter(!is.na(monster_type)) %>%
  mutate(pc_level = factor(pc_level, levels = max(pc_level, na.rm = T):1),
         monster_type = factor(
           monster_type, 
           levels = rev(levels(monster_type)))) %>%
  group_by(pc, pc_level, monster_type) %>%
  summarize(kills = sum(monster_count)) %>%
  group_by(pc, pc_level) %>%
  mutate(kills_pct = kills / sum(kills)) %>%
  ggplot(aes(x = pc_level, y = kills_pct, fill = monster_type)) +
  geom_bar(stat = 'identity') +
  facet_grid(pc ~ ., switch = 'y') +
  scale_x_discrete(expand = c(0, 0)) +
  scale_y_continuous(labels = scales::percent, expand = c(0, 0)) +
  coord_flip() +
  scale_fill_brewer(
    'Creature Type:', palette = 7, direction = -1,
    guide = guide_legend(reverse = T)) +
  theme_bw() + 
  theme(axis.text.y = element_text(size = 12),
        strip.text = element_text(angle = 180),
        panel.spacing = unit(10, "pt"),
        legend.position = 'top') +
  labs(x = '', y = 'Creatures Killed', title = title)

Perception of difficulty

The “objective” measure of a creature’s difficulty is its Challenge Rating (CR), which is what Paizo uses to balance encounters. However, some fights prove more difficult than others, because of PC specializations, terrain, spells used to date, or just crazy dice.

Changes over time

Comparing PC level to CR, we normally assess an enemy as “Tough” if it has a CR roughly equal to the PC’s level, a “Boss” if its CR is higher than our level, and a “Grunt” if its CR is lower than our level. This is consistent over time.

However, as the graph below indicates, we’ve been increasingly willing to code enemies as “Tough” even if their CR is well below our own. That’s not necessarily because we’re finding fights more difficult—rather, we may be encountering enemies we’ve previously coded as Toughs, and coding them as Toughs again, rather than updating their difficulty to Grunt. Enemies with CR above ours—who are often unique, one-time foes—are consistently coded as Bosses.

title <- 'Creature CR versus PC Level'
subtitle <- 'Lines represent the trend in our assesments over time'

lab <- data.frame(
  x = c(11, 12, 5),
  y = c(3, 10, 8),
  text = factor(c('Grunt', 'Tough', 'Boss'), 
                levels = c('Grunt', 'Tough', 'Boss')),
  color = c('black', 'white', 'white'), 
  fill = viridis::viridis(3, end = 0.9, direction = -1),
  stringsAsFactors = F)

dat %>%
  filter(monster_cr != 0 & !is.na(monster_type)) %>%
  ggplot(aes(x = pc_level, y = monster_cr, color = monster_type)) +
  geom_abline(slope = 1, intercept = 0, linetype = 'dashed') +
  geom_smooth(method = 'lm', se = F, alpha = 0.75) +
  geom_count(alpha = 0.75, position = position_dodge(width = 0.5)) +
  geom_label(dat = lab, alpha = 0.35, color = 'black',
             aes(x = x, y = y, label = text, fill = text)) +
  viridis::scale_color_viridis(
    discrete = T, direction = -1, end = 0.9) +
  viridis::scale_fill_viridis(
    discrete = T, direction = -1, end = 0.9) +
  guides(color = F, fill = F) +
  scale_size('Number of Creatures', breaks = c(1, 2, 5, 10)) +
  scale_y_continuous(
    expand = c(0, 0.1), limits = c(0, 15), breaks = 1:15) +
  scale_x_continuous(
    expand = c(0, 0.1), limits = c(0, 13), breaks = 1:13) +
  coord_fixed() + theme_axes() + theme(legend.position = 'top') +
  labs(x = 'PC Level', y = 'Challenge Rating', title = title, subtitle = subtitle)

Easier than expected

These are enemies that we either

  • Coded as ‘Tough’ even though their CR was higher than ours, or
  • Coded as ‘Grunt’ even though their CR was close to ours (CR over 3/4 of PC level)
dat %>%
  filter(monster_cr != 0) %>%
  mutate(cr_level = monster_cr / pc_level) %>%
  filter(
    (monster_type == 'Tough' & cr_level > 1) | 
      (monster_type == 'Grunt' & cr_level > 0.75)) %>%
  select(monster_cr, pc_level, monster_name, monster_type) %>%
  unique() %>%
  knitr::kable(col.names = c('Creature CR', 'PC Level', 
                             'Creature Name', 'Assessed Difficulty'))
Creature CR PC Level Creature Name Assessed Difficulty
2 1 Blindheim Tough
1 1 Rat Grunt
1 1 Weird mutant rat Grunt
1 1 Gremlin Grunt
2 1 Gremlin leader Tough
2 1 Repair Drone Grunt
2 1 Brown mold Grunt
3 2 Cerebral fungus Grunt
3 2 Vegepygmy boss Tough
2 2 Doctor robot Grunt
4 3 Gearsman Tough
6 5 Will-o’ Wisp Tough
7 6 Chuul Tough
8 7 Fancy Looking Gearsman Tough
9 8 Deathtrap Ooze Tough
7 9 Nanite Swarm Grunt
7 9 Belker Grunt
9 10 Tentacled Plant Mass Grunt
12 11 Dolphin-Eel thing Tough
12 11 N. Gin Attendant Tough

Harder than expected

These are enemies who we either

  • Coded as ‘Boss’ even though they had CR at or below us, or
  • Coded as ‘Tough’ even though they had CR well below ours (less than 3/4 of PC level)
dat %>%
  filter(monster_cr != 0) %>%
  mutate(cr_level = monster_cr / pc_level) %>%
  filter(
    (monster_type == 'Boss' & cr_level <= 1) |
      (monster_type == 'Tough' & cr_level < 0.75)) %>%
  select(monster_cr, pc_level, monster_name, monster_type) %>%
  unique() %>%
  knitr::kable(col.names = c('Creature CR', 'PC Level', 
                             'Creature Name', 'Assessed Difficulty'))
Creature CR PC Level Creature Name Assessed Difficulty
3 4 Sanvil Trett Boss
3 4 Garmin Ulreth Boss
5 5 Marrow Boss
3 6 Ogre Tough
3 6 Nalakai Tough
6 6 Draig Boss
6 9 Warhammer Wobot Tough
6 9 Achaierai Tough
4 9 Allip Tough
6 9 Hungry Fog Tough
7 10 Shambling Mounds Tough
6 10 Mi-Go Tough
6 11 Mi-Go Tough
11 11 Pale Stranger Boss
8 11 Neh-Thalggu Tough
8 11 Intellect Devourer Tough
6 12 Floating Gray Orb? Tough
12 12 Possessed Ebony-Green Dragon Boss
8 12 Neh-Thalggu Tough
12 12 Paajet Boss

Classification

Just for fun, what if we use “\(k\)-nearest neighbors” classification to model what how the party will classify enemies?

# Make dataset with no missing values
knn_train <- dat %>% 
  filter(!is.na(monster_cr) & !is.na(pc_level) & !is.na(monster_type)) %>%
  select(monster_cr, pc_level, monster_type)

# Run models for all k 1-20 and find the one with the best in-sample classification
# note, this is dumb because it over-fits the data
results <- rep(NA, 20)
for(k in seq_along(results)){
    mod <- class::knn.cv(
      knn_train[, c('monster_cr', 'pc_level')], 
      cl = knn_train$monster_type, 
      k = k)
    results[k] <- mean(knn_train$monster_type == mod)
}

# What k is best?
which(results == max(results))
## [1] 2
# If there are more than one, pick the first / lowest k
k <- which(results == max(results))[1]

# Predict for all combinations of monster CR and pc level
grid <- na.omit(expand.grid(
  unique(round(dat$monster_cr)),
  unique(dat$pc_level)))

mod <- class::knn(
  train = knn_train[, c('monster_cr', 'pc_level')], 
  test = grid, 
  cl = knn_train$monster_type, 
  # set k to the model with the best in-sample prediction
  k = k)

data.frame(class = mod, x = grid[, 2], y = grid[, 1]) %>%
  ggplot(aes(x = x, y = y, color = class)) + 
  geom_abline(slope = 1, intercept = 0, color = 'gray', size = 2) + 
  geom_point(size = 5, shape = 15) + theme_bw() + 
  viridis::scale_color_viridis('Assessed\nDifficulty', discrete = T, direction = -1) +
  coord_fixed() +
  labs(x = 'PC Level', y = 'Creature CR', title = 'Predicted Player Classification',
       subtitle = 'Based on "k-nearest neighbors" classification with k = 2')

Fun stuff

Alignment Chart

Heard you like charts so we made a chart of your alignment chart so you can categorize while you moralize

dat$monster_alignment[dat$monster_alignment == 'N'] <- 'NN'

dat <- dat %>%
  mutate(align_order = substring(monster_alignment, 1, 1),
         align_order = factor(align_order, levels = c('L', 'N', 'C'),
                              labels = c('Lawful', 'Neutral', 'Chaotic')),
         align_moral = substring(monster_alignment, 2, 2),
         align_moral = factor(align_moral, levels = c('E', 'N', 'G'),
                              labels = c('Evil', 'Neutral', 'Good')))


dat_melt <- dat[rep(1:nrow(dat), dat$monster_count), 
                c('pc', 'monster_cr', 'align_order', 'align_moral')]

jit <- 0.35
dat_melt %>% filter(!is.na(align_moral) & !is.na(pc)) %>%
  ggplot(aes(x = align_order, y = align_moral, size = monster_cr, color = pc)) +
  geom_point(alpha = 0.75, position = position_jitter(width = jit, height = jit)) +
  scale_x_discrete(drop = F) +
  scale_y_discrete(drop = F) +
  scale_size('Challenge Rating') +
  scale_color_manual('Player Character', values = pal) +
  geom_hline(yintercept = c(2.5, 1.5)) + 
  geom_vline(xintercept = c(2.5, 1.5)) +
  coord_fixed() +
  theme_bw() +
  theme(panel.grid = element_blank(), 
        axis.text = element_text(size = 12)) +
  labs(x = '', y = '')

Boss list!

Let’s not forget our dear departed Bosses!

dat$monster_name[grepl('BINOX', dat$monster_name)] <- 'Binox the Builder'

dat %>% 
  filter(monster_type == 'Boss') %>% 
  select(monster_name, pc, sub_location, kill_method) %>%
  knitr::kable(col.names = c(
    'This creature...', 'was killed by...', 'in the...', 'with the...'
  ))
This creature… was killed by… in the… with the…
Four Armed Zombie (Boss) Hardad Buried Ship Holy Water
Meyanda the Android Telum Buried Ship Shocking Grasp
Sanvil Trett Corrin City of Torch Greatsword
Garmin Ulreth Telum City of Torch Color Spray
Birdfoot Corrin Birdfoot’s Hideout Greatsword
Marrow Telum Smilers’ Hideout Shocking Grasp
Mutant Mantacore Corrin Scrapwall Longbow
Wraith Corrin Haunted Canyon Greatsword
Helskaarg Corrin Arena Greatsword
Kulgara Telum Hellion’s Lair Scimitar
Draig Hardad Hellion’s Lair Magma Pistol
Helion Hardad Hellion’s Lair EMP Pistol
Varisian Technic League Spy Corrin Android Factory Chainsaw
Giant Excavator Robot Corrin Smokewood Chainsaw
Cavenenchin the Leukodaemon Corrin Choking Tower Chainsaw
Furkus Xoud Telum Choking Tower Shocking Grasp
8 Legged Robot Hardad Choking Tower EMP Pistol
Binox the Builder Telum Machine Caves Shocking Grasp
Rainbow Mi-Go Hardad Fungal Caves Pistol
Pale Stranger Corrin Scar of the Spider Chainsaw
Froghemoth Hardad Scar of the Spider Laser Pistol
The-Stars-Whispers Corrin Dominion Dropship Chainsaw
Annihilator Robot Corrin Dominion Hive Chainsaw
Possessed Ebony-Green Dragon Hardad Dominion Hive Laser Pistol
Paajet Telum Dominion Hive Shocking Grasp
Giant Chitinous Monster Nadia Dominion Hive Flame Strike
Doc Hellbroth Corrin Red Reaver Chainsaw
Technic League Captain Corrin Night Market Chainsaw
Kevoth Kul, The Black Sovereign Telum Black Sovereign’s Palace Scimitar
Tek Makul (Tech McCool) Telum Black Sovereign’s Palace Frigid Touch
Ghartone (We think) Telum Black Sovereign’s Palace Shocking Grasp
Dybbuk Corrin Black Sovereign’s Palace Holy Chainsaw

Notes

Sometimes we add notes to the sheet. That makes the following entries… notable.

dat %>%
  filter(!is.na(notes)) %>%
  select(monster_name, pc, sub_location, kill_method, notes) %>%
  knitr::kable(col.names = c(
    'Creature', 'PC', 'Location', 'Method', 'Notes'
  ))
Creature PC Location Method Notes
Three-legged robot Corrin City of Torch Greatsword FIRST BLOOD
Blindheim Hardad Caves Grit Up Close and Personal after it blinded half the party
Rat Nadia Caves Burning Hands Two-in-one!
Weird mutant rat Corrin Caves Greatsword CRITICAL HIT
Green Slime Telum Buried Ship Scimitar First Kill!
Four-armed undead thing Corrin Buried Ship Greatsword Foul monstrosities!
Four-armed undead thing Hardad Buried Ship Holy Water 3 throws, 3 kills
Four-armed undead thing Telum Buried Ship Scimitar Flank!
Flying Fungus Hardad Buried Ship Grit Up Close and Personal after it paralyzed Nadia
Four Armed Zombie (Boss) Hardad Buried Ship Holy Water Missed but the 1 damage was enough!
Vegepygmy minion Corrin Buried Ship Greatsword Attack of opportunity
Vegepygmy minion Telum Buried Ship Scimitar Critical hit!
Vegepygmy boss Corrin Buried Ship Greatsword Natural 20
Doctor robot Telum Buried Ship Shocking Grasp Critical + electric vulnerability
Flying robot Telum Buried Ship Shocking Grasp Critical + electric vulnerability… AGAIN
Doctor robot Telum Buried Ship Scimitar After going down and getting anesthetized
Giant tentacle monster Nadia Buried Ship Crossbow bolt Sniped in melee combat!
Half-orc Corrin Buried Ship Greatsword AoO from Nadia’s “FLEE” command
Ratfolk Corrin Buried Ship Greatsword Charge
Half-orc Telum Buried Ship Color Spray Knocks them out, to be killed
Half-orc Telum Buried Ship Scimitar The half-orc that wouldn’t die
Thug Corrin City of Torch Greatsword This was, admittedly, pretty unprovoked
Garmin Ulreth Telum City of Torch Color Spray Not really dead, just subdued
Hatchet Bandit Nadia Aldonard’s Grave Burning Hands An act of incredible valor
Birdfoot’s Orc Corrin Birdfoot’s Hideout Greatsword 2 if by crit
Birdfoot’s Orc Nadia Birdfoot’s Hideout Burning Hands 1 by smoke inhalation
Birdfoot Corrin Birdfoot’s Hideout Greatsword After Birdfoot murderizes Hardad
Kij the Bird Nadia Birdfoot’s Hideout Hammer Coup de Grace
Pile of Moving Junk Hardad Dinvaya’s Hideout Pistol Crit
Smiler Corrin Smilers’ Hideout Greatsword Saving Ratfolk
Smiler Nadia Smilers’ Hideout Burning Hands After a well-placed grenade
Human Corpse Nadia Smilers’ Hideout Fireball Nadia’s Very First Fireball
Lobotomites Corrin Smilers’ Hideout Greatsword Cleaving majestically
Smiler Corrin Smilers’ Hideout Greatsword Dramatically appropriate crit
Smiler Corrin Smilers’ Hideout Longbow Did I mention he killed him with a long bow.
Mutant Mantacore Corrin Scrapwall Longbow WTF with this longbow! (stole from Telum)
Wraith Corrin Haunted Canyon Greatsword Crit
The Dish Hardad Scrapwall Cylex And boom goes the dynamite
Helion Hardad Hellion’s Lair EMP Pistol Crit!
Yauguai Corrin Farm Chainsaw First chainsaw kill!
Deformed Andoid Corrin Android Factory Chainsaw SO MANY CRITS
Deformed Andoid NA Android Factory NA Blew herself up
Varisian Technic League Spy Corrin Android Factory Chainsaw But she didn’t die, because we saved her (only to kill her later?)
Gearsman Hardad Android Factory EMP Pistol It’s super effective!
Gearsman Telum Android Factory Shocking Grasp OVERKILL
Warhammer Wobot Hardad Choking Tower EMP Pistol After 3 of 4 pass out!
Cavenenchin the Leukodaemon Corrin Choking Tower Chainsaw Perfectly timed crit on an OA
Furkus Xoud Telum Choking Tower Shocking Grasp FINALLY
Crane Nadia Choking Tower Fireball No seriously a crane
Xoud’s Assistant Corrin Choking Tower Chainsaw 2 Crits in a row!
8 Legged Robot Hardad Choking Tower EMP Pistol Started combat with a crit to stun, never got an attack
Mishtu Corrin Choking Tower Chainsaw Smite Evil
Binox the Builder Telum Machine Caves Shocking Grasp Crit on the first round
Mi-Go Telum Fungal Caves Scimitar Crit as it disengaged to protect the caster!
Rainbow Mi-Go Hardad Fungal Caves Pistol Throwback kill
Gearsmen Battleguard Hardad Black Sovereign’s Palace EMP Pistol Stealth kills! apparently

What else?

There’s always more to look into! Give me all your ideas, questions, feedback, etc.