Twilight Imperium is a space opera strategy board game by Fantasy Flight Games. The modern board game enthusiasts at Board Game Geek rank it in their top ten, but it is also a very complicated game: there are dozens of factions, an expansive technology tree, and almost 50 pages of rules in the living rules reference.
Twilight Imperium is a complex strategy game that is, at its heart, about diplomacy and playing the table — that is, it’s a social game (though having the right plastic on the table helps too). Winning the game isn’t as simple as just picking the right faction or technology tree.
I do not think data analysis will tell you how to win the game at your table, but I think it is fun to do these kinds of analysis and think the results are an interesting statement on the current “meta” in this version of the game.
Faction Strength
Twilight Imperium has two dozen different factions, each with their own abilities and flavor of play. I tend to actually really enjoy playing as factions many consider weak, including the Arborec and Mentak Coalition.
Model Math
For each event E, we have P players (6 in this case) who are playing one of 1-P factions. Each game has one winner drawn from the categorical distribution based on the factions’ abilities.
Here is the code in Stan:
data {int<lower=1> E; // number of eventsint<lower=1> P; // total number of playersint<lower=1, upper=P> players[E, 6]; // player IDs per eventint<lower=1, upper=6> winner[E]; // winner index (1-6)}parameters {vector[P] ability; // faction strength}model { ability ~ normal(0, 1); // priorsfor (e in1:E) {vector[6] logit_p;for (k in1:6) { logit_p[k] = ability[players[e, k]]; } winner[e] ~ categorical_logit(logit_p); }}
Potential ways to improve this analysis:
Account for speaker position and slice strength.
Faction Combos
The presence of another faction in the game can have a large effect on a faction’s strength. For instance, the Yssaril benefit from other factions like the Naalu that have powerful agents they can copy.
Table 1
Heroes
Most Played Heroes
One of my pet theories is that good heroes are ones that you can play consistently.
Table 2
Faction
Hero Purged %
Titans of Ul
94.06%
Empyrean
91.33%
Naalu Collective
88.52%
Naaz-Rokha Alliance
87.41%
Keleres ~ Mentak
86.39%
Yssaril Tribes
86.20%
Emirates of Hacan
79.22%
Universities of Jol-Nar
76.13%
Vuil'raith Cabal
73.81%
Nekro Virus
72.70%
Yin Brotherhood
72.67%
Winnu
67.30%
Keleres ~ Xxcha
61.54%
Arborec
60.57%
Faction
Hero Purged %
Federation of Sol
58.81%
Mahact Gene-Sorcerers
58.06%
L1Z1X Mindnet
58.06%
Ghosts of Creuss
56.64%
Clan of Saar
45.24%
Embers of Muaat
41.42%
Argent Flight
38.55%
Mentak Coalition
35.18%
Keleres ~ Argent
31.96%
Sardakk N'orr
29.88%
Nomad
17.59%
Barony of Letnev
15.40%
Xxcha Kingdom
0.00%
Here we can see which factions have the most usable heroes 1. The Titans hero gives a nice planet upgrade in the home system and gets used in almost every single game. On the other hand, the Barony hero is much more situational.
1 Note that as of Codex III, the Xxcha hero is the only one that cannot be purged.
Hero Importance
To determine hero strength, we might ask: for each faction, to what extent does playing the hero help them win?
Figure 1: Estimated effect of purging each faction’s hero on win probability in Twilight Imperium. Points show posterior means; error bars show 95% credible intervals. Values above zero indicate factions that tend to perform better when their hero is purged.
Unsurprisingly, the Winnu game seems most dependent on their ability to use their hero, which can let them play the primary ability imperial strategy card at any point.
Mecatol Rex
Is it worth it to take Mecatol Rex?
Table 3
Scored Mecatol?
Games
Win Rate
Yes
6040
25.08%
No
12044
12.45%
Figure 2: Histogram of simulated changes in win probability from scoring at least one point from Mecatol Rex. The minimum result from the simulation is an increase of 12.1 pp.
Source Code
---title: Overthinking Twilight Imperium, 4th Editiondescription: What are the strongest factions? Who has the best heroes?date: 2025-04-21categories: - Twilight Imperium - Stanformat: html: code-tools: true toc: trueexecute: echo: false output: true warning: false error: false freeze: auto---_Twilight Imperium_ is a space opera strategy board game by Fantasy Flight Games. The modern board game enthusiasts at Board Game Geek [rank it in their top ten](https://boardgamegeek.com/browse/boardgame?sort=rank&sortdir=asc), but it is also a very complicated game: there are dozens of factions, an expansive technology tree, and almost 50 pages of rules in the [living rules reference](https://images-cdn.fantasyflightgames.com/filer_public/51/55/51552c7f-c05c-445b-84bf-4b073456d008/ti10_pok_living_rules_reference_20_web.pdf).I found a [data set from thousands of games played online asynchronously](https://lookerstudio.google.com/u/0/reporting/3b435bf2-2100-488c-a424-130f1d22ebb0/page/pE58B) and will use this as a starting point to answer my personal questions about faction and hero strength.::: {.callout-tip}_Twilight Imperium_ is a complex strategy game that is, at its heart, about diplomacy and playing the table --- that is, it's a social game (though having the right plastic on the table helps too). Winning the game isn't as simple as just picking the right faction or technology tree.I do not think data analysis will tell you how to win the game at your table, but I think it is fun to do these kinds of analysis and think the results are an interesting statement on the current "meta" in this version of the game.:::## Faction Strength_Twilight Imperium_ has two dozen different factions, each with their own abilities and flavor of play. I tend to actually really enjoy playing as factions many consider weak, including the Arborec and Mentak Coalition. ::: {.callout-note collapse="true"}### Model MathFor each event **E**, we have **P** players (6 in this case) who are playing one of 1-P factions. Each game has one winner drawn from the [categorical distribution](https://mc-stan.org/docs/functions-reference/bounded_discrete_distributions.html#categorical-distribution) based on the factions' abilities.Here is the code in Stan:```standata { int<lower=1> E; // number of events int<lower=1> P; // total number of players int<lower=1, upper=P> players[E, 6]; // player IDs per event int<lower=1, upper=6> winner[E]; // winner index (1-6)}parameters { vector[P] ability; // faction strength}model { ability ~ normal(0, 1); // priors for (e in 1:E) { vector[6] logit_p; for (k in 1:6) { logit_p[k] = ability[players[e, k]]; } winner[e] ~ categorical_logit(logit_p); }}```:::```{r}#| label: setuplibrary(tidyverse)library(rstan)library(reactable)library(htmltools)library(htmlwidgets)library(gt)library(ggrepel)library(glmnet)source(here::here("code/common.R"))load_or_run_stan_model <-function( model_path, data, file_path, ...) {if (file.exists(file_path)) { fitted_model <-readRDS(file_path)return(fitted_model) } fitted_model <- rstan::stan(file = model_path,data = data, ... )saveRDS(fitted_model, file = file_path)return(fitted_model)}``````{r}#| label: load-data# Load data, selecting 6 player PoK games# and only taking their final state.ti_data <- readr::read_delim( here::here("notes/ti4-analysis/data/async_data.csv")) |>select(-`isPOK...13`) |>rename(isPOK =`isPOK...6`) |>filter(isPOK) |>filter(`Player Count`==6) |>filter(factionNameWinner !="Incomplete Match") |>filter(!isHomebrewMatch) |>filter(roundPlusFinal =="final") |>filter(Timestamp >=1682398800) |>mutate(game_id =as.character(setupTimestamp)) |>group_by(game_id) |>filter(max(score, na.rm =TRUE) ==10) |>ungroup()``````{r}#| label: prepare-stan-data-factionsdf <- ti_data |>mutate(faction_id =as.integer(factor(factionName)))n_games <-nrow(distinct(df, game_id))game_positions <- df |>distinct(game_id, factionsInGame) |>mutate(factionName =str_split(factionsInGame, ", ")) |>select(game_id, factionName) |>unnest(factionName) |>group_by(game_id) |>mutate(seat =row_number()) |>ungroup()stan_ready <- df |>left_join(game_positions, by =c("game_id", "factionName")) |>mutate(win =`Winning Faction`== factionName) |>group_by(game_id) |>filter(n() ==6) |>arrange(game_id, seat) |>reframe(players =list(faction_id),winner =which(win) ) |>head(n_games)stan_data <-list(E =nrow(stan_ready),P =max(df$faction_id),players =do.call(rbind, stan_ready$players),winner = stan_ready$winner)``````{r}#| label: fit-faction-model#| cache: truefit_base <-load_or_run_stan_model( here::here("notes/ti4-analysis/ti4_20250414.stan"),data = stan_data,file_path = here::here("notes/ti4-analysis/data/faction_model.rds"),iter =5000,chains =4,cores =4,open_progress =FALSE,)``````{r}#| label: simulate-factions#| cache: trueposterior <- rstan::extract(fit_base)draws <- posterior$abilityn_draws <-nrow(posterior$ability)n_sim_games <-1000faction_levels <-levels(factor(df$factionName))F <-length(faction_levels)draw_ids <-sample(1:nrow(draws), n_draws)posterior_subset <- draws[draw_ids, ]# Store marginal win prob per draw × factionmarginal_matrix <-matrix(NA_real_, nrow = n_draws, ncol = F)for (d in1:n_draws) { win_total <-numeric(F) appearances <-integer(F)for (g in1:n_sim_games) { participants <-sample(1:F, 6) # 6 unique factions scores <- posterior_subset[d, participants] probs <-exp(scores) /sum(exp(scores)) win_total[participants] <- win_total[participants] + probs appearances[participants] <- appearances[participants] +1 } marginal_matrix[d, ] <- win_total / appearances}``````{r}faction_strength <-tibble(faction = faction_levels,mean =colMeans(marginal_matrix),lower =apply(marginal_matrix, 2, quantile, 0.025),upper =apply(marginal_matrix, 2, quantile, 0.975))ggplot(faction_strength, aes(x =reorder(faction, mean), y = mean)) +geom_point() +geom_errorbar(aes(ymin = lower, ymax = upper), width =0.2) +geom_hline(yintercept =1/6, linetype ="dashed") +coord_flip() +labs(x ="Faction", y ="Estimated win probability", title ="Marginal Win Probabilities with 95% Credible Intervals")```Potential ways to improve this analysis:+ Account for speaker position and slice strength.## Faction CombosThe presence of another faction in the game can have a large effect on a faction's strength. For instance, the Yssaril benefit from other factions like the Naalu that have powerful agents they can copy.```{r}df <- ti_data |>mutate(faction_id =as.integer(factor(factionName)))n_games <-nrow(distinct(df, game_id))game_positions <- df |>distinct(game_id, factionsInGame) |>mutate(factionName =str_split(factionsInGame, ", ")) |>select(game_id, factionName) |>unnest(factionName) |>group_by(game_id) |>mutate(seat =row_number()) |>ungroup()stan_ready <- df |>left_join(game_positions, by =c("game_id", "factionName")) |>mutate(win =`Winning Faction`== factionName) |>group_by(game_id) |>filter(n() ==6) |>arrange(game_id, seat) |>reframe(players =list(faction_id),winner =which(win), ) |>head(n_games)stan_data <-list(E =nrow(stan_ready), # Number of gamesP =max(df$faction_id), # Number of faction typesplayers =do.call(rbind, stan_ready$players), # Player ids, PxEwinner = stan_ready$winner # Winner index)``````{r}#| label: fit-pairwise-model#| cache: truefit <-load_or_run_stan_model( here::here("notes/ti4-analysis/ti4_pairs.stan"),data = stan_data,file_path = here::here("notes/ti4-analysis/data/faction_pairs.rds"),iter =5000,chains =4,cores =4,open_progress =FALSE,)``````{r}#| label: sample-posterior-faction-combosposterior <- rstan::extract(fit)interact_draws <- posterior$interact # This has shape [iterations, P, P]faction_levels <-levels(factor(df$factionName))P <-length(faction_levels)interaction_table <-map_dfr(1:P, function(i) {map_dfr(1:P, function(j) {tibble(from = faction_levels[j],to = faction_levels[i],effect = interact_draws[, i, j] ) })})interaction_summary <- interaction_table %>%group_by(from, to) %>%summarize(mean =mean(effect),lower =quantile(effect, 0.025),upper =quantile(effect, 0.975),.groups ="drop" ) %>%arrange(desc(mean))``````{r}#| label: tbl-faction-combos#| column: page-right#interaction_filtered <- interaction_summary #%>% select(from, to, mean, lower, upper)faction_names <-unique(sort(interaction_summary$to))tbl <-reactable( interaction_summary,filterable =FALSE,searchable =FALSE,columns =list(to =colDef(name ="Target Faction"),from =colDef(name ="With Faction"),mean =colDef(name ="Effect", format =colFormat(digits =3)),lower =colDef(name ="Lower Bound", format =colFormat(digits =3)),upper =colDef(name ="Upper Bound", format =colFormat(digits =3)) ),elementId ="faction-interaction-table")# UI + JS Filtering Hookbrowsable(tagList( tags$label("Select Target Faction:"), tags$select(id ="faction-selector",onchange ="filterTable()",lapply(faction_names, function(name) tags$option(name)) ), tags$script(HTML(" function filterTable() { var selected = document.getElementById('faction-selector').value; var table = Reactable.getInstance('faction-interaction-table'); if (!table) return; table.setFilter('to', selected); } " )),onRender(tbl, " function(el, x) { // Set initial filter to the first option var table = Reactable.getInstance('faction-interaction-table'); if (table) { table.setFilter('to', document.getElementById('faction-selector').value); } } ") ))```## Heroes### Most Played HeroesOne of my pet theories is that good heroes are ones that you can play consistently.```{r}#| label: tbl-leader-use#| column: page-right#| layout-ncol: 2#| classes: plainleaders <- ti_data |>select(game_id, factionName, leaders) |>mutate(leaders =map(leaders, ~ jsonlite::fromJSON(.x))) %>%unnest_wider(leaders) |>mutate(hero_purged = hero =="purged",hero_unlocked = hero !="locked" )leaders |>group_by(factionName) |>summarize(purged =sum(hero_purged)/n()) |>arrange(desc(purged)) |>gt() |>cols_label(factionName ="Faction",purged ="Hero Purged %" ) |>fmt_percent(columns =c(purged) ) |>tab_options(table.font.name ="inherit",column_labels.font.weight ="bold",table.margin.right =px(20),table.margin.left =px(20) ) |>gt_split(row_every_n =14)```Here we can see which factions have the most _usable_ heroes [^xxcha]. The Titans hero gives a nice planet upgrade in the home system and gets used in almost every single game. On the other hand, the Barony hero is much more situational.[^xxcha]: Note that as of Codex III, the Xxcha hero is the only one that _cannot_ be purged.### Hero ImportanceTo determine hero strength, we might ask: for each faction, to what extent does playing the hero help them win?```{r}#| label: prepare-stan-data-leaderscannot_purge <-c("Xxcha Kingdom")stan_ready <- df |>left_join(game_positions, by =c("game_id", "factionName")) |>left_join(leaders, by =c("game_id", "factionName")) |>mutate(win =`Winning Faction`== factionName) |>mutate(can_purge =!(factionName %in% cannot_purge)) |>group_by(game_id) |>filter(n() ==6) |>arrange(game_id, seat) |>reframe(players =list(faction_id),winner =which(win),hero_purged =list(hero_purged),hero_unlocked =list(hero_unlocked),can_purge =list(can_purge) ) |>head(n_games)stan_data <-list(E =nrow(stan_ready),P =max(df$faction_id),players =do.call(rbind, stan_ready$players),hero_purged =do.call(rbind, stan_ready$hero_purged),hero_unlocked =do.call(rbind, stan_ready$hero_unlocked),can_purge =do.call(rbind, stan_ready$can_purge),winner = stan_ready$winner)``````{r}#| label: fit-hero-modelfit <-load_or_run_stan_model( here::here("notes/ti4-analysis/ti4_hero.stan"),data = stan_data,file_path = here::here("notes/ti4-analysis/data/hero_fit.rds"),iter =1000,chains =4,cores =4,open_progress =FALSE,)``````{r}#| label: fig-hero-effects#| fig-cap: Estimated effect of purging each faction’s hero on win probability in Twilight Imperium. Points show posterior means; error bars show 95% credible intervals. Values above zero indicate factions that tend to perform better when their hero is purged.posterior <- rstan::extract(fit)draws_hero <- posterior$hero_effecthero_effect <-tibble(faction = faction_levels,mean =colMeans(draws_hero),lower =apply(draws_hero, 2, quantile, 0.025),upper =apply(draws_hero, 2, quantile, 0.975))ggplot(hero_effect, aes(x =reorder(faction, mean), y = mean)) +geom_point() +geom_errorbar(aes(ymin = lower, ymax = upper), width =0.2) +geom_hline(yintercept =0, linetype ="dashed") +coord_flip() +labs(x ="Faction",y ="Hero Effect on Win Log-Odds",title ="Importance of Hero by Faction", )```Unsurprisingly, the Winnu game seems most dependent on their ability to use their hero, which can let them play the primary ability imperial strategy card at any point. ## Mecatol RexIs it worth it to take Mecatol Rex?```{r}#| label: 20250424-093538df <- ti_data |>mutate(faction_id =as.integer(factor(factionName)))n_games <-nrow(distinct(df, game_id))mr_data <- ti_data |>mutate(objectives =map(objectives, ~ jsonlite::fromJSON(.x))) |>select(game_id, factionName, objectives) |>mutate(objectives =map(objectives, function(x) {# If it's already a list, return it as isif (is.list(x)) {return(x) } # Otherwise, treat it as a character vectorelseif (is.character(x)) {return(as.list(x)) }# Fallback caseelse {return(list(as.character(x))) } })) |> tidyr::unnest_longer(objectives) |>filter(objectives =="Custodian/Imperial") |>mutate(took_mr =1) |>select(-objectives)stan_ready <- df |>left_join(mr_data, by =c("game_id", "factionName")) |>replace_na(list(took_mr =0)) |>mutate(win =`Winning Faction`== factionName) |>group_by(game_id) |>filter(n() ==6) |>arrange(game_id) |>reframe(players =list(faction_id),winner =which(win),took_mr =list(took_mr) ) |>head(n_games)posterior <- rstan::extract(fit_base)ability_means <-colMeans(posterior$ability)stan_data <-list(E =nrow(stan_ready), # Number of gamesP =max(df$faction_id), # Number of faction typesplayers =do.call(rbind, stan_ready$players), # Player ids, PxEtook_mr =do.call(rbind, stan_ready$took_mr), # Scored Mecatol Rex?winner = stan_ready$winner, # Winner indexprior_ability = ability_means, # use from previous modelability_sd =0.25)``````{r}#| label: tbl-mecatol-rawdf |>left_join(mr_data, by =c("game_id", "factionName")) |>replace_na(list(took_mr =0)) |>mutate(win =`Winning Faction`== factionName) |>select(win, took_mr) |>count(took_mr, win) |>mutate(took_mr =if_else(took_mr ==1, "Yes", "No")) |>arrange(took_mr, win) |>group_by(took_mr) |>summarize(games_played =sum(n),win_rate =sum(n[win ==TRUE]) /sum(n),.groups ="drop" ) |>arrange(desc(took_mr)) |>gt() |>cols_label(took_mr ="Scored Mecatol?",win_rate ="Win Rate",games_played ="Games" ) |>fmt_percent(columns =c("win_rate") ) |>tab_options(table.font.name ="inherit",column_labels.font.weight ="bold", )``````{r}#| label: fit-mr-model#| include: false#| output: falsefit_mr <-load_or_run_stan_model( here::here("notes/ti4-analysis/ti4_mr.stan"),data = stan_data,file_path = here::here("notes/ti4-analysis/data/ti4_mr.rds"),iter =2500,chains =4,cores =4,open_progress =FALSE,)``````{r}#| label: draws-mecatol#| include: false#| output: falseposterior <- rstan::extract(fit_mr)draws_mr <- posterior$mecatol``````{r}#| label: 20250424-110009#| cache: truen_sim_games <-1000n_draws <-length(draws_mr)faction_levels <-levels(factor(df$factionName))F <-length(faction_levels)mr_effects <-numeric(n_draws)for (d in1:n_draws) { win_diff <-numeric(n_sim_games)for (g in1:n_sim_games) { participants <-sample(1:F, 6) ability_draw <- posterior$ability[d, participants]# Without MR score_no_mr <- ability_draw prob_no_mr <-exp(score_no_mr) /sum(exp(score_no_mr))# With MR for player 1 score_with_mr <- ability_draw score_with_mr[1] <- score_with_mr[1] + draws_mr[d] prob_with_mr <-exp(score_with_mr) /sum(exp(score_with_mr))# Difference in win prob for player 1 win_diff[g] <- prob_with_mr[1] - prob_no_mr[1] } mr_effects[d] <-mean(win_diff)}```::: {#fig-mec-effect}```{r}#| label: mec-effecttibble(effect = mr_effects) |>ggplot(aes(x = effect)) +geom_histogram(fill ="black") +#geom_vline(aes(xintercept = mean(effect)), linetype = "dashed") +labs(title ="Effect of Scoring from Mecatol Rex",x ="Change in Win Probability",y ="Posterior Draw Count" )```Histogram of simulated changes in win probability from scoring at least one point from Mecatol Rex. The minimum result from the simulation is an increase of `{r} round(min(mr_effects)*100, 1)` pp.:::