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)
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.
# Create session-level df
time_dat <- dat %>%
group_by(session_date, chapter) %>%
summarize(kills = sum(monster_count)) %>%
full_join(sessions) %>%
ungroup() %>%
mutate(chapter = factor(chapter, labels = c(
'The Fires\nof Creation',
'Lords of Rust',
'The Choking Tower',
'Valley of the\nBrain Collectors',
'Palace of\nFallen Stars'))) %>%
arrange(session_date) %>%
tidyr::replace_na(list(kills = 0))
Our earliest and more recent sessions were definitely more violent than those in Chapters 3 and 4. Chapter 2, “Lords of Rust,” currently holds the highest body count. However, Chapter 5, “Palace of Fallen Stars,” is on pace to surpass it.
title <- 'Creatures killed by session'
# create labels
chapter_labels <- time_dat %>%
ungroup() %>%
filter(!is.na(chapter)) %>%
mutate(session = as.numeric(as.factor(session_date))) %>%
group_by(chapter) %>%
summarize(x = mean(session),
# x_right = max(session) + 0.5,
y = max(kills)) %>%
mutate(
chapter = factor(chapter, labels = c(
'The Fires\nof Creation',
'Lords of Rust',
'The Choking Tower',
'Valley of the\nBrain Collectors',
'Palace of\nFallen Stars')))
# Offset due to mixed session
# chapter_labels[1, 'x_right'] <- 5
chapter_labels[1, 'x'] <- 2.75
chapter_labels[2, 'x'] <- 7.75
# chapter_labels[nrow(chapter_labels), 'x_right'] <- NA
ggplot(time_dat, aes(x = as.factor(session_date), y = kills, fill = chapter)) +
geom_bar(stat = 'identity', width = 0.8) +
geom_text(data = chapter_labels, aes(x, y, label = chapter),
vjust = -1) +
# geom_vline(xintercept = chapter_labels$x_right, col = 'gray40') +
scale_x_discrete(labels = function(x) format.Date(x, '%b %d \'%y')) +
scale_y_continuous(expand = c(0, 0), limits = c(0, max(time_dat$kills) + 5)) +
viridis::scale_fill_viridis(discrete = T, option = 'D') +
guides(fill = F) +
theme_axes() +
theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
labs(x = 'Session', y = 'Killing Blows', title = title)
We’ve already seen the overall player data—Corrin and Hardad are leading; Telum and Nadia are behind.
title <- 'Creatures killed by player'
pc_dat <- dat %>%
filter(!is.na(pc)) %>%
group_by(pc) %>%
summarize(kills = sum(monster_count, na.rm = T))
ggplot(pc_dat, aes(x = pc, y = kills, label = kills, fill = pc)) +
geom_bar(stat = 'identity') +
geom_label(fill = 'white') +
scale_y_continuous(expand = c(0, 0), limits = c(0, max(pc_dat$kills) + 5)) +
scale_fill_manual("PC", values = pal) +
guides(fill = F) +
labs(x = 'Player Character', y = 'Killing Blows') +
theme_axes() + theme(axis.text.x = element_text(size = 12))
However, that’s been changing over time. The biggest change has been Hardad, who started off trailing the party, caught up to Telum and Nadia by 6th level, and has been outpacing Corrin for the past few levels.
Use the tabs to explore!
# Group data for next few plots (pc / session)
pc_dat <- dat %>%
group_by(session_date, pc) %>%
summarize(kills = sum(monster_count)) %>%
full_join(sessions) %>%
arrange(session_date) %>%
tidyr::replace_na(list(kills = 0)) %>%
ungroup() %>%
mutate(pc = factor(pc, levels = unique(pc)))
title <- 'Total kills by PC per level'
dat %>%
group_by(pc_level, pc) %>%
summarize(kills = sum(monster_count)) %>%
filter(!is.na(pc) & pc_level < 14) %>%
group_by(pc) %>%
arrange(pc_level) %>%
mutate(total_kills = cumsum(kills)) %>%
ggplot(aes(x = as.factor(pc_level), y = total_kills, color = pc, group = pc)) +
geom_line() + geom_point() +
scale_y_continuous(expand = c(0, 0)) +
scale_color_manual("PC", values = pal) +
guides(color = F) +
annotate('text', x = c(6, 9, 11, 12), y = c(75, 65, 55, 30),
label = c('Corrin', 'Hardad', 'Telum', 'Nadia')) +
theme_axes() +
labs(x = 'PC Level', y = 'Total Kills', title = title)
title <- 'Total kills by PC per session'
pc_dat %>%
filter(!is.na(pc)) %>%
group_by(pc) %>%
arrange(session_date) %>%
mutate(total_kills = cumsum(kills)) %>%
ggplot(aes(x = as.factor(session_date), y = total_kills, color = pc, group = pc)) +
geom_line() + geom_point() +
scale_color_manual("PC", values = pal) +
guides(color = F) +
annotate('text', x = c(15, 20, 25, 30), y = c(85, 60, 50, 30),
label = c('Corrin', 'Hardad', 'Telum', 'Nadia')) +
scale_x_discrete(labels = function(x) format.Date(x, '%b %d \'%y')) +
scale_y_continuous(expand = c(0, 0)) +
theme_axes() +
theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
labs(x = 'Session', y = 'Total kills', title = title)
title <- 'Kills by PC per session'
max_kills <- pc_dat %>% group_by(session_date) %>%
summarize(kills = sum(kills)) %>%
select(kills) %>% drop %>% max(na.rm = T)
pc_dat %>% filter(!is.na(pc)) %>%
ggplot(aes(x = as.factor(session_date), y = kills, fill = pc)) +
geom_bar(stat = 'identity', width = 0.8) +
scale_x_discrete(labels = function(x) format.Date(x, '%b %d \'%y')) +
scale_y_continuous(expand = c(0, 0), limits = c(0, max_kills + 1)) +
scale_fill_manual("PC", values = pal) +
theme_axes() +
theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
labs(x = 'Session', y = 'Kills', title = title)
Use the tabs to explore!
The party definitely left more bodies behind at particular locations. In general, less-civilized but populous locations see higher death counts. Scrapwall was particularly brutal.
title <- 'Kills by location'
dat %>%
filter(!is.na(pc)) %>%
group_by(location, pc) %>%
summarize(kills = sum(monster_count)) %>%
ggplot(aes(x = location, y = kills, fill = pc)) +
geom_bar(stat = 'identity') +
scale_fill_manual("PC:", values = pal, guide = guide_legend(reverse = T)) +
scale_y_continuous(expand = c(0, 0), limits = c(0, 105)) +
coord_flip() +
labs(y = 'Creatures Killed', x = '', title = title) +
theme_axes() +
theme(axis.text.y = element_text(size = 12),
legend.position = 'top')
However, the single most deadly place we’ve been is definitely the Black Sovereign’s Palace, which just goes to show that even with crazy dangerous wilderness regions and lawless settlements, political struggles are the most violent.
title <- 'Kills by specific site'
dat %>%
filter(!is.na(pc)) %>%
mutate(location = factor(location, levels = unique(location))) %>%
# tidyr::replace_na(list(sub_location = 'Other')) %>%
group_by(location, sub_location, pc) %>%
summarize(kills = sum(monster_count)) %>%
ggplot(aes(x = sub_location, y = kills, fill = pc)) +
geom_bar(stat = 'identity') +
scale_y_continuous(expand = c(0, 0), limits = c(0, 58)) +
coord_flip() +
facet_grid(location ~ ., scales = 'free_y', space = 'free', switch = 'y') +
theme_axes() +
theme(strip.text.y = element_text(angle = 180),
legend.position = 'top') +
scale_fill_manual("PC:", values = pal, guide = guide_legend(reverse = T)) +
labs(x = '', y = 'Monsters Killed', title = title)
Different PCs stand out in different locations. Excluding locations with fewer than 10 kills, where did each PC stand out the most - that is, where did they claim their highest proportion of the party’s kills?
top_pc <- dat %>%
group_by(location, sub_location, pc) %>%
summarize(pc_kills = sum(monster_count)) %>%
group_by(location, sub_location) %>%
mutate(location_kills = sum(pc_kills),
pc_kill_pct = pc_kills / location_kills) %>%
filter(location_kills >= 10) %>%
group_by(pc) %>%
arrange(desc(pc_kill_pct)) %>%
slice(1:3) %>%
select(pc, location, sub_location, location_kills, pc_kills, pc_kill_pct) %>%
mutate(pc_kill_pct = scales::percent(round(pc_kill_pct, 2)))
# ggplot(top_pc, aes(x = sprintf('%s, %s', sub_location, location),
# y = pc_kill_pct,
# fill = pc)) +
# geom_bar(stat = 'identity') +
# facet_wrap(~ pc, scales = 'free_y', ncol = 1) +
# scale_y_continuous(labels = scales::percent) +
# scale_fill_manual(values = pal) +
# guides(fill = FALSE) +
# labs(x = 'Percent', y = '') +
# theme_bw() +
# coord_flip()
view_top <- function(dat, char){
dat %>%
filter(pc == char) %>%
knitr::kable(
col.names = c('PC', 'Location', 'Site', 'Party kills',
sprintf('%s kills', char), 'Proportion'),
align = c('lllrrr')) %>%
print()
}
Corrin stood out most fighting the Dominion, and at the beginning of the game, where starting with a two-handed weapon and high strength gives a big advantage.
top_pc %>% view_top('Corrin')
| PC | Location | Site | Party kills | Corrin kills | Proportion |
|---|---|---|---|---|---|
| Corrin | Scar of the Spider | Dominion Dropship | 17 | 8 | 47% |
| Corrin | Scar of the Spider | Dominion Hive | 33 | 14 | 42% |
| Corrin | Torch | Buried Ship | 45 | 19 | 42% |
Hardad stood out most fighting in the Fungal Caves, where we encountered many flying enemies, as well as taking all the kills Corrin didn’t in the Dominion Dropship. More recently, he cleaned up at the Black Sovereign’s Palace, a target-rich environment. Note: Hardad also killed nearly every enemy in the Machine Caves with his EMP pistol, but that was only 8 enemies total and was dropped from this analysis.
top_pc %>% view_top('Hardad')
| PC | Location | Site | Party kills | Hardad kills | Proportion |
|---|---|---|---|---|---|
| Hardad | Starfall | Technic League Compound | 16 | 12 | 75% |
| Hardad | Scar of the Spider | Fungal Caves | 21 | 9 | 43% |
| Hardad | Starfall | Black Sovereign’s Palace | 55 | 23 | 42% |
Telum stood out most in the ship under Torch, where he was the only early-game source of electric damage for the party. He also did very well in Xoud’s Tower, where he claimed a higher-than-usual number of kills against the robots and weird magical creatures (including Xoud himself!).
top_pc %>% view_top('Telum')
| PC | Location | Site | Party kills | Telum kills | Proportion |
|---|---|---|---|---|---|
| Telum | Torch | Buried Ship | 45 | 14 | 31% |
| Telum | Smokewood | Choking Tower | 26 | 7 | 27% |
| Telum | Scrapwall | Smilers’ Hideout | 30 | 5 | 17% |
Nadia stood out most in the early portions of Scrapwall, where there were numerous foes weak to blasting spells and grenades. She also increased her body count a lot at the Black Sovereign’s Palace, facing many weaker enemies with new metamagic.
top_pc %>% view_top('Nadia')
| PC | Location | Site | Party kills | Nadia kills | Proportion |
|---|---|---|---|---|---|
| Nadia | Scrapwall | Birdfoot’s Hideout | 15 | 5 | 33% |
| Nadia | Starfall | Black Sovereign’s Palace | 55 | 15 | 27% |
| Nadia | Scrapwall | Smilers’ Hideout | 30 | 7 | 23% |
Each PC has 1-2 clear standout weapons, with Hardad bringing the most diversity to the team. Some surprises include that Nadia has killed more enemies with a hammer than with her summons, Hardad’s killed more people with Holy Water than with the Arc Pistol, and Corrin’s chainsaw only just surpassed the greatsword around level 13.
title <- 'Killing blows by weapon'
dat %>%
filter(!is.na(pc)) %>%
group_by(kill_method, pc) %>%
summarize(kills = sum(monster_count)) %>%
arrange(kills) %>%
ungroup() %>%
mutate(kill_method = factor(kill_method, levels = unique(kill_method))) %>%
ggplot(aes(x = kill_method, y = kills, fill = pc)) +
geom_col() +
facet_grid(pc ~ ., scales = 'free_y', space = 'free',
switch = 'y') +
scale_y_continuous(expand = c(0, 0)) +
scale_fill_manual(values = pal) + guides(fill = F) +
theme_axes() +
coord_flip() +
labs(y = 'Killing blows', x = '', title = title)
title <- 'Kills by weapon per session'
max_kills <- dat %>% group_by(kill_method) %>%
summarize(kills = sum(monster_count)) %>%
select(kills) %>% drop() %>% max()
labs <- dat %>%
group_by(pc, kill_method) %>%
arrange(pc_level) %>%
summarize(pc_level = max(pc_level),
kills = sum(monster_count)) %>%
filter(kills > 5)
killdat <- dat %>%
filter(!is.na(pc)) %>%
group_by(pc_level, pc, kill_method) %>%
summarize(kills = sum(monster_count)) %>%
group_by(pc, kill_method) %>%
arrange(pc_level) %>%
mutate(total_kills = cumsum(kills)) %>%
ungroup()
for(i in unique(killdat$kill_method)){
temp <- subset(killdat, kill_method == i)
killdat <- rbind(
killdat,
tibble(pc_level = min(temp$pc_level) - 1,
pc = unique(temp$pc),
kill_method = i,
kills = 0,
total_kills = 0))
}
ggplot(killdat,
aes(x = pc_level, y = total_kills,
color = pc, group = kill_method)) +
geom_line() + geom_point() +
ggrepel::geom_label_repel(
data = labs,
aes(x = pc_level, y = kills, label = kill_method),
show.legend = F) +
scale_y_continuous(expand = c(0, 0), limits = c(0, max_kills + 5)) +
scale_color_manual("PC", values = pal) +
theme_axes() +
labs(x = 'Level', y = 'Kills', title = title)
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 |
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?
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()
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))
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)
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)
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)
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.
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)
These are enemies that we either
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 |
These are enemies who we either
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 |
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')
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 = '')
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 |
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 |
There’s always more to look into! Give me all your ideas, questions, feedback, etc.