#Load packages
rm(list=ls()) # Clear all objects in the global environment
library(tidyverse) # For data manipulation + graphing
library(gtsummary) # Summary statistics
library(knitr) # To compile HTML document
library(readr) # To read and import survey data
library(ggh4x) # Advanced layout options
library(huxtable) # Tables
library(lubridate) # To work with dates and times

#Import Data
public <- read_csv("Data/TT Survey 4 (Public).csv")
admin <- read_csv("Data/TT Survey 4 (Admin).csv")

#Admin identifier
public <- public %>% mutate(admin=0)
admin <- admin %>% mutate(admin=1)

#Merge and drop previous
tt<- bind_rows(public, admin)
rm(public, admin)

#Cleaning
df <- tt[, 2:56] #Creating a separate work dataframe.

#Clean var names
colnames(df) <- c('sqtime', 'ttime', 'regular', 'sl',
                  'slserver', 'clan', 'tzone', 'admresp', 'admprof',
                  'admfair', 'admoverall', 'twork', 'learn',
                  'balance', 'skill', 'toxic', 'maprot', 'Anvil', 'Basrah',
                  'Belaya', 'Black Coast', 'Chora', 'Fallujah', 'Fools',
                  'Goose Bay', 'Gorodok', 'Harju', 'Kamdesh', 'Kohat', 'Kokan',
                  'Lashkar', 'Logar', 'Manic', 'Mestia','Mutaha','Narva',
                  'Skorpo', 'Sumari', 'Tallil', 'Yehorivka', 'moderot', 'modepref',
                  'fogless', 'fogfreq', 'fogcomment', 'basecamp','comms', 'asset', 'gen_comment', 'admin',
                  'adm_pct_raas', 'adm_pct_aas', 'adm_pct_fraas', 'adm_pct_tc', 'adm_pct_inv')

df <- df %>% subset(select = -c(fogcomment, gen_comment))

#Designate certain variables as ordered factor variables

#sqtime
df$sqtime <- ordered(df$sqtime, levels = c("Fewer than 100 hours", 
                                            "Between 100 - 600 hours", 
                                            "Between 600 - 1000 hours", 
                                            "1000+ hours"))

levels(df$sqtime) <- c("Fewer than 100 hours", 
                       "Between 100 - 600 hours", 
                       "Between 600 - 1,000 hours", 
                       "1,000+ hours")

#ttime
df$ttime <- ordered(df$ttime, levels = c("Fewer than 6 months", 
                                         "Between 6 months - 1 year",
                                         "Between 1 - 2 years", "Over 2 years"))

#regular, sl, ext.clan, fogless awareness
df$regular <- ordered(df$regular, levels = c("Yes", "No"))
df$sl <- ordered(df$sl, levels = c("Yes", "No"))
df$clan <- ordered(df$clan, levels = c("Yes", "No"))
df$fogless <- ordered(df$fogless, levels = c("Yes", "No"))

#fogless frequency
df$fogfreq <- ordered(df$fogfreq, levels = c("Never", 
                                            "Less Often (Monthly)", 
                                            "As Is (Bi-Weekly)", 
                                            "More Often (Weekly)"))

#timezones
df$tzone <- ordered(df$tzone, levels = c("US East", 
                                            "US Central", 
                                            "US Pacific", 
                                            "UK/Continental Europe","Other"))

#Mode preference

## More RAAS
df <- df %>% mutate(more_raas = case_when(
  str_detect(modepref, "More RAAS")==TRUE ~ 1, 
  TRUE ~ 0))

## More AAS
df <- df %>% mutate(more_aas = case_when(
  str_detect(modepref, "More AAS")==TRUE ~ 1, 
  TRUE ~ 0))

## More FRAAS
df <- df %>% mutate(more_fraas = case_when(
  str_detect(modepref, "(IE RAAS with all points visible)")==TRUE ~ 1, 
  TRUE ~ 0))

## More TC
df <- df %>% mutate(more_tc = case_when(
  str_detect(modepref, "More TC")==TRUE ~ 1, 
  TRUE ~ 0))

## More Invasion
df <- df %>% mutate(more_inv = case_when(
  str_detect(modepref, "More Invasion")==TRUE ~ 1, 
  TRUE ~ 0))

#df2 <- df %>% subset(select = c(modepref, more_raas, more_aas, more_fraas, more_tc, more_inv))


# Convert Admin mode proportions variables to numeric
df <- df %>% 
  mutate(across('adm_pct_raas': 'adm_pct_inv', str_replace, '%', '')) %>%
  mutate(across('adm_pct_raas': 'adm_pct_inv', as.numeric))
  
#Create dummy variable for over 1000 hours
df <- df %>% mutate(over1k = case_when(sqtime == "1,000+ hours" ~ "Yes",
                                       sqtime == "Fewer than 100 hours" ~ "No",
                                        sqtime == "Between 100 - 600 hours" ~ "No",
                                        sqtime == "Between 600 - 1,000 hours" ~ "No"))

df$over1k <- ordered(df$over1k, levels = c("Yes", "No"))

#Create dummy variable for having been at TT for over/under a year

df <- df %>% mutate(over1tt = case_when(ttime == "Fewer than 6 months" ~ "No",
                                        ttime == "Between 6 months - 1 year" ~ "No",
                                        ttime == "Between 1 - 2 years" ~ "Yes",
                                        ttime == "Over 2 years" ~ "Yes"))

df$over1tt <- ordered(df$over1tt, levels = c("Yes", "No"))

#Clean up map data - Recode checks to 1 and not checks to 0
df <- df %>% mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == "TT should play less of:" ~ 1,
                            . == "TT should play more of:" ~ 2,
                                        TRUE ~ 0)))


#Graph logic and themes

#Create blank theme to use in all pie charts
blank_theme <- theme_minimal()+
  theme(
  axis.title.x = element_blank(),
  axis.title.y = element_blank(),
  panel.border = element_blank(),
  panel.grid=element_blank(),
  axis.ticks = element_blank(),
  plot.title=element_text(size=14, face="bold"))

#Table logic and themes

#Categorical variable tables
cat_table_fmt <-function(htable) {
  htable %>% as_hux_table() %>%  set_width(1) %>% 
  set_all_padding(4) %>% 
  set_outer_padding(0) %>%
  theme_article() %>% .[-2, ] %>% 
    add_footnote("* N is the observation count. ** CI stands for confidence interval") %>%
    set_italic(final(1), 1, TRUE) 
}

#Continious variable tables
cont_table_fmt <-function(htable) {
  htable %>% as_hux_table() %>%  set_width(1) %>% 
  set_all_padding(4) %>% 
  set_outer_padding(0) %>%
  theme_article() %>% 
    add_footnote("* N is the observation count. ** CI stands for confidence interval") %>%
    set_italic(final(1), 1, TRUE) 
}

#Difference tables

#Continuous
diff_table_fmt <-function(diff_table) {
  diff_table %>% set_width(1) %>% 
  set_all_padding(4) %>% 
  set_outer_padding(0) %>%
  theme_article() %>% 
    map_background_color(everywhere, 1:6,
                       by_cases(diff_table[, 5]<0.05 & 
                                  diff_table[, 5]!= ">0.9" &  
                                  diff_table[, 4] < 0 ~ "#e06666")) %>% 
      map_background_color(everywhere, 1:6,
                       by_cases(diff_table[, 6]<0.05 & 
                                  diff_table[, 6]!= ">0.9" &  
                                  diff_table[, 4] < 0 ~ "#D7261E")) %>% 
    map_background_color(everywhere, 1:6,
                       by_cases(diff_table[, 5]<0.05 & 
                                  diff_table[, 5]!= ">0.9" &  
                                  diff_table[, 4] > 0 ~ "#6aa84f")) %>% 
      map_background_color(everywhere, 1:6,
                       by_cases(diff_table[, 6]<0.05 & 
                                  diff_table[, 6]!= ">0.9" &  
                                  diff_table[, 4] > 0 ~ "#38761d")) %>% 
  map_text_color(everywhere, 1:6,
                       by_cases(diff_table[, 5]<0.05 & 
                                  diff_table[, 5]!= ">0.9" &  
                                  diff_table[, 4] < 0 ~ "white")) %>% 
      map_text_color(everywhere, 1:6,
                       by_cases(diff_table[, 6]<0.05 & 
                                  diff_table[, 6]!= ">0.9" &  
                                  diff_table[, 4] < 0 ~ "white")) %>% 
    map_text_color(everywhere, 1:6,
                       by_cases(diff_table[, 5]<0.05 & 
                                  diff_table[, 5]!= ">0.9" &  
                                  diff_table[, 4] > 0 ~ "white")) %>% 
      map_text_color(everywhere, 1:6,
                       by_cases(diff_table[, 6]<0.05 & 
                                  diff_table[, 6]!= ">0.9" &  
                                  diff_table[, 4] > 0 ~ "white")) %>% 
    set_background_color(1, everywhere, "white") %>% 
  set_text_color(1, everywhere, "black") %>% 
  add_footnote("* P-value calculated using Welch's T-test. ** Q-value calculated using Bonferroni's correction") %>% 
  set_italic(final(1), 1, TRUE) 
  
}

# Dichotomous
dich_table_fmt <- function(dich_table) {
  dich_table %>% set_width(1) %>% 
  set_all_padding(4) %>% 
  set_outer_padding(0) %>%
  theme_article() %>% 
      map_background_color(everywhere, 1:4,
                       by_cases((dich_table[, 4]<0.05 | dich_table[, 4]=="<0.001") & dich_table[, 3] > dich_table[, 2] & dich_table[, 4]!=">0.9"  ~ "#e06666")) %>% 
        map_background_color(everywhere, 1:4,
                       by_cases((dich_table[, 4]<0.05 | dich_table[, 4]=="<0.001") & dich_table[, 3] < dich_table[, 2] & dich_table[, 4]!=">0.9"  ~ "#6aa84f")) %>%
  map_text_color(everywhere, 1:4,
                       by_cases(dich_table[, 4]<0.05 & dich_table[, 4]!=">0.9" ~ "white")) %>% set_background_color(1, everywhere, "white") %>% 
  set_text_color(1, everywhere, "black") %>% 
  add_footnote("* P-value calculated using Pearson's Chi-squared test") %>% 
  set_italic(final(1), 1, TRUE)
}

#Load maps database TT
tt_maps <- read_csv("Data/DBLog_kills.csv") %>% mutate(server="TT") %>% rename(id = "match_id")

#Other servers
sof <- read_csv("Data/sof.csv") %>% select(-server) %>% mutate(server="SOF")
ktf <- read_csv("Data/ktf.csv") %>% select(-server) %>% mutate(server="KTF")
unn <- read_csv("Data/unnamed.csv") %>% select(-server) %>% mutate(server="UNN")

#Dates
tt_maps$startTime <- as_datetime(tt_maps$startTime)
tt_maps$endTime <- as_datetime(tt_maps$endTime)
sof$startTime <- as_datetime(sof$startTime)
sof$endTime <- as_datetime(sof$endTime)
ktf$startTime <- as_datetime(ktf$startTime)
ktf$endTime <- as_datetime(ktf$endTime)
unn$startTime <- as_datetime(unn$startTime)
unn$endTime <- as_datetime(unn$endTime)


#Merge
maps_actual <- bind_rows(tt_maps, ktf, unn, sof)

# Clean seed, skirmish and Jensens
maps_actual <- maps_actual %>% 
  filter(!grepl('Seed|Training|Skirmish|Proving', layer))

#Clean rounds fewer than 50 deaths for TT
maps_actual<- maps_actual %>% 
  mutate(total_deaths = team1Deaths + team2Deaths) %>% 
  mutate(total_deaths = ifelse(is.na(total_deaths), 100, total_deaths)) %>% 
  filter(total_deaths >=50)

# Modes
maps_actual <- maps_actual %>% mutate(mode = case_when(str_detect(layer, "RAAS") ~ "RAAS",
                                         str_detect(layer, "AAS") ~ "AAS",
                                         str_detect(layer, "Tanks") ~ "AAS",
                                         str_detect(layer, "Invasion") ~ "Invasion",
                                         str_detect(layer, "TC") ~ "Territory Control"))

#Strange rounds (no winner recorded and not invasion...) DBLOG MATCHES
#maps_actual <- maps_actual %>% mutate(non_inv_blank =if_else(mode!="Invasion" & is.na(winner), 1, 0))

# Clean time
maps_actual$start_utc <- ymd_hms(maps_actual$startTime, tz = "UTC")
maps_actual$end_utc <- ymd_hms(maps_actual$endTime, tz = "UTC")

#Convert to EST
maps_actual$start_est <- with_tz(maps_actual$start_utc, "America/New_York")
maps_actual$end_est <- with_tz(maps_actual$end_utc, "America/New_York")

#Duration
maps_actual <- maps_actual %>% mutate(dur_min = (end_est - start_est)/60)
maps_actual$dur_min <- as.numeric(maps_actual$dur_min)

#Remove rounds less than 15 min
maps_actual <- maps_actual %>% filter(dur_min>=15) %>% filter(dur_min<=120)

# Clean up map names

maps_actual <- maps_actual %>% mutate(maps = case_when(map=="Al Basrah" ~ "Basrah",
                                                       map=="Belaya Pass" ~ "Belaya",
                                                       map=="Fool's Road" ~ "Fools",
                                                       map=="Kohat Toi" ~ "Kohat",
                                                       map=="Manic-5" ~ "Manic",
                                                       map=="Manicouagan" ~ "Manic",
                                                       map=="Sumari Bala" ~ "Sumari",
                                                       map=="Tallil Outskirts" ~ "Tallil")) %>% mutate(maps=ifelse(is.na(maps), map, maps))

#Remove Galactic contention stuff from UNN servers
maps_actual <- maps_actual %>% 
  filter(!is.na(maps)) %>% 
  filter(!str_detect(layer, "GC ")) %>% 
  filter(!is.na(mode))

# Extract Day, time of day, Week, month and year based on US EST
maps_actual <- maps_actual %>% 
  mutate(dow = wday(start_est, label = TRUE, abbr = FALSE)) %>% 
  mutate(day= lubridate::as_date(start_est)) %>% 
  mutate(time = hms::as_hms(start_est)) %>% 
  mutate(month = month(start_est, label = TRUE, abbr = FALSE)) %>% 
  mutate(year = year(start_est)) %>% 
  mutate(week = week(start_est)) %>% 
  unite("ym", month:year, sep = " ", remove = FALSE)

##Generate dummy variables for prime time, pre-prime, late-night
maps_actual <- maps_actual %>% 
  mutate(prime = ifelse(time >=  hms::as_hms('17:30:00') , 1, 0))

maps_actual <- maps_actual %>%  
  mutate(pre_prime = ifelse(time <  hms::as_hms('17:30:00') &
                             time >=  hms::as_hms('12:00:00') , 1, 0))

maps_actual <- maps_actual %>% 
  mutate(gremlins = ifelse(time >  hms::as_hms('00:00:00') &
                              time <  hms::as_hms('12:00:00') , 1, 0))

maps_actual <- maps_actual %>% mutate(tod = case_when(pre_prime==1 ~ "Pre-Prime",
                                            prime==1 ~ "Prime Time",
                                            gremlins==1 ~ "Gremlins"))

maps_actual$tod <- ordered(maps_actual$tod, levels = c("Pre-Prime", "Prime Time", "Gremlins"))

# Weekends
maps_actual <- maps_actual %>% 
  mutate(weekend = case_when(dow=="Friday" & prime == 1 ~ 1,
                             dow=="Saturday" ~ 1,
                             dow=="Sunday" ~ 1,
                             TRUE ~ 0))

# Ticket bleed and ICO

maps_actual <- maps_actual %>% 
  mutate(post_bleed = ifelse(day>= lubridate::as_date('2023-03-15'), "Yes", "No")) %>%
  mutate(post_ico = ifelse(day>= lubridate::as_date('2023-09-28'), "Post ICO", "Pre-ICO"))

maps_actual$post_bleed <- ordered(maps_actual$post_bleed, levels = c("Yes", "No"))
maps_actual$post_ico <- ordered(maps_actual$post_ico, levels = c("Post ICO", "Pre-ICO"))  


#Big maps
maps_actual <-  maps_actual %>% mutate(big_map= case_when(maps=="Anvil" ~ 1,
                                                          maps=="Black Coast" ~ 1,
                                                          maps=="Goose Bay" ~ 1,
                                                          maps=="Gorodok" ~ 1,
                                                          maps=="Harju" ~ 1,
                                                          maps=="Kohat" ~ 1,
                                                          maps=="Lashkar" ~ 1,
                                                          maps=="Manic" ~ 1,
                                                          maps=="Skorpo" ~ 1,
                                                          maps=="Tallil" ~ 1,
                                                          maps=="Yehorivka" ~ 1,
                                                          .default = 0))


# Duration buckets
maps_actual <- maps_actual %>% mutate(dur_buck = case_when(dur_min<=20 ~ "Below 20 min",
                                                           dur_min>20 & dur_min<=40 ~ "Between 20 and 40 min",
                                                           dur_min>40 & dur_min<=60 ~ "Between 40 and 60 min",
                                                           dur_min>60 ~ "60+ min"))

maps_actual$dur_buck <- ordered(maps_actual$dur_buck, levels = c("Below 20 min", 
                                                                 "Between 20 and 40 min", 
                                                                 "Between 40 and 60 min", 
                                                                 "60+ min"))

#Re-order and select vars
maps_actual <- maps_actual %>% select(id, server, maps, layer, mode, big_map, dur_min, dur_buck,
                                      start_utc:end_est, post_bleed, post_ico,
                                      dpm:dpm_team2, dow:weekend) %>% select(-dpm, -team1Deaths, -dpm_team1, -team2Deaths, -dpm_team2)

#Ordering servers
maps_actual$server <- ordered(maps_actual$server, levels = c("TT", "UNN", "KTF", "SOF")) 

#Segmenting TT
maps_actual_all <- maps_actual
maps_actual <- maps_actual %>% filter(server=="TT" )

#Removing some invalid rounds from other servers
maps_actual_all <- maps_actual_all %>% 
  mutate(inval = ifelse(dur_min>109 & tod =="Gremlins", 1, 0)) %>% filter(inval==0) %>% select(-inval)

#Removing raw data
rm(ktf, sof, tt_maps, unn)

Executive Summary

In this document, we present the results of Tactical Triggernometry’s fourth annual survey. The results of our last annual survey can be found here. Readers may access the R-code used to generate the results by clicking the collapsible “code” buttons adjacent to all the tables, statistical tests, charts, and plots. The ability to peer review this work ensures the analysis presented is transparent and reproducible. I encourage readers to examine the code, find any mistakes, and run the code themselves. All the datasets and the material used to generate the report can be found here.

The main findings from each section are summarised below:

SectionAssessmentYear on YearKey Observations

Demographics

ExperiencedMore long term playersMost respondents have over 1,000 hours in Squad, have been at TT for longer than a year, and consider TT their primary server.

Slightly more than a third of respondents are external clan members.

The server is split roughly 50-50 between the US Eastern time zone and others.

Administration

HighSlight Improvement

We score approximately 8/10 across all four questions relating to administration (response time, fairness, professionalism and overall quality).

Server Environment

High*No Change

Respondents rate us highly on gameplay level and teamwork (over 8/10). They rate us well on “overall server environment” and environment for SLs (roughly 7.5/10).

The main areas of concern as ever are the learning environment and team balance. In these categories, we received ratings of roughly 6.5/10. Further discussion here.

Rules & Enforcement

HighSlight Improvement

We score approximately 8/10 across all three questions relating to rules and enforcement (base camping enforcement, comms enforcement and the asset claim system).

* Excluding Balance & Learning environment

Maps, modes & events:

Survey data on map preferences were compared to actual data from the server covering over 4,000 valid rounds (i.e., not seeding layers, training range, map rolls, etc.).

  • The top 3 underplayed maps are Black Coast, Manic, and Mestia.

  • The top 3 overplayed maps are Chora, Kokan, and Mutaha.

  • Only a handful of maps are played more than once per day.

  • TT is a “95-5” server when it comes to game modes. RAAS and AAS make up approximately 95% of what we play. The majority of respondents seem happy with this as there is no majority support for more of any mode.

  • A bi-weekly format for Fogless Fridays is broadly supported.

ICO & Round Duration:

Using data from more than 14,000 rounds across four servers, I analysed the impact of the ICO on round durations:

  • On average, across all modes, round durations have increased by between 8 and 10 minutes. at TT. This represents a jump of approximately 20% from the pre-ICO average.

  • The amount of 60+ minute rounds has more than tripled post ICO at TT.

  • Other servers have seen a far more subdued impact mainly because they had higher round times on average pre-ICO. There has been a convergence in round times post ICO.

Subgroup analysis:

  • Non-admins rate us the same or marginally lower across all categories. Admins are more likely to be experienced players (1,000+ hours) and more likely to prefer fogless RAAS and AAS.

  • Experienced players (1,000+ hours) rate us the same across most categories apart from marginal differences in the Admin professionalism and server environment categories. Experienced players favour more AAS and fogless RAAS, but this preference remains a minority view even among experienced players.

  • External clan members are less satisfied with some policies including base camping enforcement and asset claim systems. External clan members are far more likely to be experienced players and unsurprisingly they tend to prefer more fogless RAAS and AAS than unaffiliated players. Overall, external clan members rate us similarly to unaffiliated players across most categories.

  • Compared to Survey 3, we have either stayed the same or marginally improved in some areas (such as base camping enforcement). Respondents are more likely to have spent over a year at TT when compared to Survey 3.

Cluster analysis:

This section uses a basic clustering algorithm to check if we can identify certain groups among the respondents. We identify four groups with a plurality of respondents rating us highly across all categories.

In the last section, Responses to comments, Affinity responds to select comments. There is an Appendix which attempts to evaluate the impact of the ICO on Squad’s player count and compares the ICO with other major releases.

Glossary

Before we can delve into the analysis, a basic glossary of statistical terms may be useful in understanding this report. For ease of reference, we have highlighted these concepts as they appear in the report. Clicking the highlighted terms will lead you back to the glossary.

Term

Definition

N

The number of observations being used in the calculation. Sometimes different across tables because of missing values (people leaving questions blank).

Frequency Distribution

A frequency distribution is just a count of the number of times an observation occurs in the data. In a scoring metric of 1-10, if 50 people gave us a 9/10 in a certain category, the frequency of '9' would be 50.

Standard deviation

Standard deviation/Variance is a common statistical measure of the amount of dispersion in the scores. A low standard deviation suggests that observations are clustered close to the mean. A high standard deviation indicates greater dispersion.

Confidence interval

When dealing with sample data (the survey) we, as readers, want to make inferences about the population (the full TT player base) from a limited sample. A confidence interval is a range of estimates for an unknown parameter computed at a confidence level (commonly 95%). The straightforward way to interpret the confidence interval is that 'we are 95% confident that the true population parameter is between the lower and the upper bounds.'

Density plot

A common visual tool used to represent a distribution. When you imagine a normal distribution, you are likely imagining a density plot. A crucial thing to note is that the area under a density plot always adds up to one (i.e., 100%).

Statistical test

A test for whether the data at hand supports a certain hypothesis. Each statistical test (such as a T-test) is a hypothesis test. In the case of the T-test, the hypothesis is that 'the 2 groups have identical means.' Rejecting that hypothesis means that the two groups have different means.

P-value

The outcome of each statistical test is a number known as the 'test statistic.' Each 'test statistic' is associated with a p-value (probability value). The p-value has a straightforward interpretation. Conventionally, if a p-value is lower than 0.05, we conclude that there is enough evidence to reject a 'null hypothesis' in a statistical test. This means that differences we are seeing between the groups are probably real (non-zero) and not due to random chance.

Statistical significance

Statistically significant is just a formal way of saying non-zero. It is really that simple - something is statistically significant if it is different from zero. We calculate p-values after statistical tests to ascertain the probability of statistical significance. Normally, we consider something significant if the p-value associated with it is less than 0.05. It is important to remember that just because something is significant (non-zero) does not mean that the effect is large.

Sometimes, we also calculate q-values too which are adjusted p-values. Q-values are just p-values adjusted for things like multiple comparisons. They have the same interpretation. In this report, I consider something to be 'strongly' significant if both the associated p and q values are less than 0.05 and 'weakly' significant if only the p-value is less than 0.05.

Chi-squared test

A standard statistical test commonly used for frequency counts. More information can be gleaned from this video on YouTube.

Welch's T-test

A standard statistical test commonly used to test for differences in means between categories. See here for more information.

Demographics

The survey was conducted from mid-September through to early November 2023. We received a total of 330 responses including admins. Critical readers may point out that the responses we receive are not from a truly random sample of players. The survey was disproportionately filled out by players that are more engaged than the random player. I have deliberately not adjusted for this bias using any weighting techniques, because it is better to show the results as they are. Rather than a random sample, readers should understand the results as representing the view of the “average regular.” I have presented confidence intervals where appropriate so that the reader can get an idea of where the “true parameter” lies and draw their own conclusion.

To start, we asked general questions such as “How long have you played Squad?” and “Do you SL often on the server?” Below, I present the findings of these questions. First, let us look at the breakdown of how long respondents have played Squad:

#Remove previous
rm(gloss, chi2test, ci, dens, fdistr, nn, pval, statsig, stdev, stest, weltest)

#Chart 1
chart1 <- df %>% select(sqtime) %>% drop_na() %>%  ggplot(aes(x = factor(1), fill = sqtime)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("How long have you played Squad?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 1: Time spent playing Squad") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart1

#Remove previous
rm(chart1)

#Table 1 - Sqtime
table1 <- df %>% select(sqtime) %>% 
  tbl_summary(type = sqtime ~ "categorical", 
              label = sqtime ~ "Hours in Squad") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "Hours in Squad") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t1 <- as_tibble(table1[["table_body"]][["stat_0"]])

#Apply theme
table1 <- cat_table_fmt(table1) %>% set_caption("Table 1: Hours played in Squad - Full Sample")

# insert_row("Table 1: Hours played in Squad - Full Sample", "", "", after = 0) %>% 
#   set_bold(1, everywhere) %>% 
#   set_align(1, everywhere, "center") %>% merge_cells(1,1:3) 

#Display
table1
Table 1: Hours played in Squad - Full Sample

Hours in Squad

N = 330

95% CI

Fewer than 100 hours3 (0.9%)0.24%, 2.9%
Between 100 - 600 hours66 (20%)16%, 25%
Between 600 - 1,000 hours66 (20%)16%, 25%
1,000+ hours195 (59%)54%, 64%
* N is the observation count. ** CI stands for confidence interval

Both the chart and the table indicate that players with over 1,000 hours are overrepresented both at TT and in the pool of respondents. This is somewhat expected. We can see that most respondents, approximately 59%, have over 1,000 hours in game, similar to last year’s survey.

The table above also reports confidence intervals for each category. Please check the glossary for a better understanding this and other terms!

The chart below shows how long people have been playing at TT:

#Remove previous
rm(table1, inline.t1)

#Chart 2
chart2 <- df %>% select(ttime) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = ttime)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("1+ Year at TT?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 2: Time spent at TT") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart2

#Remove previous
rm(chart2)

#Table 2 - ttime
table2 <- df %>% select(ttime) %>% 
  tbl_summary(type = ttime ~ "categorical", 
              label = ttime ~ "Time at TT") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "Time at TT") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t2 <- as_tibble(table2[["table_body"]][["stat_0"]])

#Apply theme
table2 <- cat_table_fmt(table2) %>% set_caption("Table 2: Time spent at TT - Full sample")

#Display
table2
Table 2: Time spent at TT - Full sample

Time at TT

N = 330

95% CI

Fewer than 6 months59 (18%)14%, 23%
Between 6 months - 1 year45 (14%)10%, 18%
Between 1 - 2 years102 (31%)26%, 36%
Over 2 years124 (38%)32%, 43%
* N is the observation count. ** CI stands for confidence interval
From Chart 2, we can see that most of the player base, 69%, has been at TT for more than a year. This is significantly higher than last year, when the figure was only 46%.

See Table 22B.

As before, confidence intervals are presented so that the reader can make their own inferences.

#Remove previous
rm(table2, inline.t2)

#Table 3 - regulars
table3 <- df %>% select(regular) %>% 
  tbl_summary(type = regular ~ "categorical", 
              label = regular ~ "Do you consider TT to be your primary server?",
              missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label =  "Do you consider TT to be your primary server?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t3 <- as_tibble(table3[["table_body"]][["stat_0"]])

#Apply theme
table3 <- cat_table_fmt(table3) %>% set_caption("Table 3: Yes/No question on whether people consider TT their primary server - Full sample")

#Display
table3
Table 3: Yes/No question on whether people consider TT their primary server - Full sample

Do you consider TT to be your primary server?

N = 330

95% CI

Yes289 (89%)85%, 92%
No37 (11%)8.2%, 15%
Did not answer4
* N is the observation count. ** CI stands for confidence interval
The overwhelming majority of respondents (around 89%) are regulars. Even the lower bound of the confidence interval is very high. This is not surprising - the survey was primarily advertised in discord.

Though a link was shown in the MOTD page when people joined the server too.

This does mean that the sample should not be taken to be representative of the general population that plays on the server. We can view the sample as a decent representation of server regulars and admins. These two groups of players invest the most in, and are the driving force behind the server.

A wide variety of external clans and communities have made TT their home server. Let’s check what percentage of our player base is affiliated with outside clans and/or communities:

#Remove previous
rm(table3, inline.t3)

#Chart 3
chart3 <- df %>% select(clan) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = clan)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Are you affiliated with an outside clan?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 3: Outside clans") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart3

#Remove previous
rm(chart3)

#Table 4 - clan
table4 <- df %>% select(clan) %>% 
  tbl_summary(type = clan ~ "categorical", 
              label = clan ~ "Are you affiliated with an outside clan?",
              missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "Are you affiliated with an outside clan?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t4 <- as_tibble(table4[["table_body"]])

#Apply theme
table4 <- cat_table_fmt(table4) %>% set_caption("Table 4: Outside clan affiliation - Full sample")

#Display
table4
Table 4: Outside clan affiliation - Full sample

Are you affiliated with an outside clan?

N = 330

95% CI

Yes111 (34%)29%, 39%
No217 (66%)61%, 71%
Did not answer2
* N is the observation count. ** CI stands for confidence interval
We can see that a substantial portion of survey respondents, and probably TT regulars as a whole, are affiliated with outside clans and communities. The point estimate is 34%, which is high.

This is statistically identical to Survey 3 from last year when the number was 34%. See Table 22B.

Because clan members are the sort of players who are more likely to respond to a survey, their views are probably overrepresented in the sample. It is my belief that the true proportion of external clan members on the server lies closer to the lower bound of the confidence interval (around 29%), although that is still quite substantial.

#Remove previous
rm(table4, inline.t4)

#Chart 4
chart4 <- df %>% select(sl) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = sl)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Do you squad lead open squads often?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 4: Squad leaders") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart4

#Remove previous
rm(chart4)

#Table 5 - Squad leaders
table5 <- df %>% select(sl) %>% 
  tbl_summary(type = sl ~ "categorical", 
              label = sl ~ "Do you squad lead open squads often?", missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "Do you squad lead open squads often?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t5 <- as_tibble(table5[["table_body"]])

#Apply theme
table5 <- cat_table_fmt(table5) %>% set_caption("Table 5: Do you squad lead open squads often? - Full sample")

#Display
table5
Table 5: Do you squad lead open squads often? - Full sample

Do you squad lead open squads often?

N = 330

95% CI

Yes118 (36%)31%, 42%
No209 (64%)58%, 69%
Did not answer3
* N is the observation count. ** CI stands for confidence interval
Roughly 36% of the sample respondents claim that they regularly lead open squads. The confidence intervals are also reported as before. It is my belief that respondents are over-reporting their time as “squad leaders,” or perhaps they have a different definition of “often” than I do. The most likely scenario is that the truth about how many respondents “often” lead open squads lies towards the lower end of the confidence interval (roughly 31%).

This is statistically identical to Survey 3 from last year when the number was identical. See Table 22B.

Next, we look at the time zones in which players are located:

#Remove previous
rm(table5, inline.t5)

#Chart 5
chart5 <- df %>% select(tzone) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = tzone)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("What time zone are you in?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 5: Time zones") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart5

#Remove previous
rm(chart5)

#Table 5 - Squad leaders
table6 <- df %>% select(tzone) %>% 
  tbl_summary(type = tzone ~ "categorical", 
              label = tzone ~ "What time zone are you in?", missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "What timezone are you in?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Save inline text results
inline.t6 <- as_tibble(table6[["table_body"]])

#Apply theme
table6 <- cat_table_fmt(table6) %>% set_caption("Table 6: Time zones - Full sample")

#Display
table6
Table 6: Time zones - Full sample

What timezone are you in?

N = 330

95% CI

US East144 (44%)38%, 49%
US Central74 (23%)18%, 28%
US Pacific61 (19%)15%, 23%
UK/Continental Europe25 (7.6%)5.1%, 11%
Other24 (7.3%)4.8%, 11%
Did not answer2
* N is the observation count. ** CI stands for confidence interval

The figures above show that TT players are roughly split evenly between US East and everywhere else. This maps roughly onto the population densities of the United States, so it is unsurprising for a North American server.

Administration

Below, I present the results for the admin categories over the full sample. Admin responses are included. Readers who want to check how admins differ from non-admins should check the relevant section.

#Remove previous
rm(table6, inline.t6)

#Build
table7 <- df %>% select('admresp':'admoverall') %>% 
  tbl_summary(type = everything() ~ "continuous",  
              statistic = all_continuous() ~ "{mean} ({sd})", 
              missing = "no", label = list('admresp' ~ 'Admin response time',
                               'admprof' ~ 'Admin professionalism',
                               'admfair' ~ 'Admin fairness',
                               'admoverall' ~ 'Overall quality of administration')) %>% add_ci() %>% 
  modify_header(stat_0 ~ "**Mean (SD)**") %>%   
  modify_footnote(everything() ~ NA) %>%  
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE) %>% add_n()

#Format
table7 <- cont_table_fmt(table7) %>% set_caption("Table 7: Summary statistics for administration - Full Sample")

#Display
table7
Table 7: Summary statistics for administration - Full Sample

Characteristic

N

Mean (SD)

95% CI

Admin response time3208.42 (1.66)8.2, 8.6
Admin professionalism3208.25 (1.75)8.1, 8.4
Admin fairness3208.35 (1.74)8.2, 8.5
Overall quality of administration3218.63 (1.67)8.4, 8.8
* N is the observation count. ** CI stands for confidence interval

Let us plot some of the distributions using density plots:

#Remove previous
rm(table7)

#Pivot the category scores to long
tempdf <- df %>% select(admresp, admprof, admfair, admoverall) %>% 
  tidyr::pivot_longer(cols = c("admresp", "admprof", "admfair", "admoverall"), 
                                   names_to = "Category", values_to = "Score")

tempdf$Category <- ordered(tempdf$Category, levels = c("admresp", "admprof", "admfair", "admoverall"))
levels(tempdf$Category) <- c("Admin response time", "Admin professionalism", "Admin fairness", "Overall quality of administration" )

# Create faceted density plots
chart6 <- ggplot(data = tempdf, aes(x=Score)) + geom_density(color="#99ccff", fill="#99ccff") + theme_classic() + 
  theme(legend.position = "none") + facet_wrap( ~ Category,  scales = "free_y", ncol=2) + xlab("") + ylab("Density") + 
  labs(title = "Chart 6: Density plots - Administrative questions") + 
  theme(plot.title = element_text(hjust = 0.5))


chart6

As we can see from both the density plots in Chart 6 and the summary statistics in Table 7, survey respondents rated the server very well in all categories pertaining to administration. The plots in Chart 6 show marked skews towards the upper end of the distribution while the mean score in all categories across administration is around 8/10. The reported standard deviations and the density plots above give the reader a good sense of the spread of the distribution of these ratings.

Once again, these scores are very similar to survey 3 from last year. See Table 22A. The density plots also look roughly the same as last year.

Server environment

In this section, we examine the questions related to server environment (teamwork, balance, etc.).

#Remove previous
rm(chart6)

#Build
table8 <- df %>% select('twork':'toxic', 'slserver') %>% 
  tbl_summary(type = everything() ~ "continuous",  
              statistic = all_continuous() ~ "{mean} ({sd})", 
              missing = "no",label = 
                list("twork" ~ "Teamwork",
                 "learn" ~ "Learning Environment",
                 "balance" ~ "Team Balance",
                 "skill" ~ "Gameplay level",
                 "slserver" ~ "Environment for SLs",
                 "toxic" ~ "Overall server environment"),
      digits = everything() ~ 2) %>% add_ci() %>% 
  modify_header(stat_0 ~ "**Mean (SD)**") %>%   
  modify_footnote(everything() ~ NA) %>%  
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE) %>% add_n() 

#Format
table8 <- cont_table_fmt(table8) %>% set_caption("Table 8: Summary statistics for server environment - Full Sample")

#Display
table8
Table 8: Summary statistics for server environment - Full Sample

Characteristic

N

Mean (SD)

95% CI

Teamwork3278.13 (1.64)7.9, 8.3
Learning Environment3246.43 (2.43)6.2, 6.7
Team Balance3236.47 (2.18)6.2, 6.7
Gameplay level3258.50 (1.64)8.3, 8.7
Overall server environment3247.53 (2.04)7.3, 7.8
Environment for SLs3147.62 (1.82)7.4, 7.8
* N is the observation count. ** CI stands for confidence interval

As before, I show density plots to illustrate the frequency distributions of the variables above:

#Remove previous
rm(table8)

#Pivot the category scores to long
tempdf <- df %>% select(twork, learn, balance, skill, toxic, slserver) %>% 
  tidyr::pivot_longer(cols = c("twork", "learn", "balance", "skill", "toxic", "slserver"), 
                                   names_to = "Category", values_to = "Score")

tempdf$Category <- ordered(tempdf$Category, levels = c("twork", "learn", "balance", "skill", "toxic", "slserver"))
levels(tempdf$Category) <- c("Teamwork", "Learning environment", "Team balance", "Gameplay level", "Overall server environment", "Environment for SLs")

# Create faceted density plots
chart7 <- ggplot(data = tempdf, aes(x=Score)) + geom_density(color="#99ccff", fill="#99ccff") +  theme_classic() + 
  theme(legend.position = "none") + facet_wrap( ~ Category,  scales = "free_y", ncol=2) + xlab("") + ylab("Density") + 
  labs(title = "Chart 7: Density plots - Server environment") + 
  theme(plot.title = element_text(hjust = 0.5))

chart7

We can see that players rate us very highly in the categories of teamwork and gameplay level (roughly 8/10), somewhat highly in the categories of server and SL environment (roughly 7.5/10), and rate us lower in the areas of team balance and learning environment (roughly 6.5/10). The story is roughly similar to last year’s survey.

See Table 7 in last year’s survey, or alternatively, Table 22A.

While it is easy to focus on the negatives (and I do so below), one should start by saying that on most of these key indicators, we are doing quite well! TT’s hallmarks have always been the gameplay level, teamwork, and the overall server environment and it is heartening to see that this remains the case. However, as with Survey 3 last year, the areas of concern are team balance and the learning environment. This is unsurprising. TT does have a reputation, fairly or otherwise, of being a server where people take the game a little too seriously from time to time.

I believe that the pejorative term, judging by some comments, is that we are a ‘bunch of sweaty tryhards.’

Regardless, the verdict is clear - we at TT should make a greater effort to create a welcoming environment. This is something that both admins and regulars should play a part in. As we can see in Table 19A, admins agree with non-admins when it comes to the learning environment.

With regards to balance, this is always a tricky topic. TT is one of a few servers with explicitly stated balance guidelines:

See our public server rules document.

TT Balance Guidelines (from server rules)

  • If a team suffers two heavy defeats (200+ tickets), the current team balance will be discussed in the TT discord with all players present. Players may be asked to swap teams to correct balance issues, however no players will be forced to swap. If you as a player believe there are issues with team balance, please bring them up constructively in all chat or directly with an admin so that we are aware of it and can work on the issue.

  • If the team balance situation is not fixed after three rounds, a team randomiser will be used. This may seem like a drastic measure, but it is necessary to ensure fair gameplay.

We track all rounds and ticket counts in Discord and tend to stick by our guidelines.

See the #maps-played channel in the TT discord.

In an analysis done in December 2022, it transpired that over the course of 1600+ rounds, we were within our guidelines roughly 95% of the time. However, the balance guidelines could be weak or insufficient. People tend to dislike getting their heads bashed over the course of two rounds before balance actions are taken.

We still feel that the guidelines are the best way to balance the server. Squad is a game where a great number of events can punish an otherwise good team (a logistics truck getting wiped during “rollout”, a bad RAAS lane, etc.) Given all the variables involved, including ever changing teams, it would be untenable to commit balance actions after every round.

Perhaps what players do not see, but admins do, is the ongoing discussions about balance behind the scenes. These discussions occur both in admin chat and in the admin channels in Discord. Now, 6.5/10 isn’t a terrible score for balance overall, but we could stand to do better. It should be stressed that our commitment to our guidelines remains firm.

Maps, modes & events

In this section, we present the results of the questions pertaining to maps, modes, and events. First, we look at the overall satisfaction with the maps and modes played on TT:

#Remove previous
rm(chart7, tempdf)

#Build
table9 <- df %>% select('maprot','moderot') %>% 
  tbl_summary(type = everything() ~ "continuous",  
              statistic = all_continuous() ~ "{mean} ({sd})", 
              missing = "no",label = 
                list("maprot" ~ "Satisfaction with map rotation",
                 "moderot" ~ "Satisfaction with mode rotation"),
      digits = everything() ~ 2) %>% add_ci() %>% 
  modify_header(stat_0 ~ "**Mean (SD)**") %>%   
  modify_footnote(everything() ~ NA) %>%  
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE) %>% add_n() 

#Format
table9 <- cont_table_fmt(table9) %>% set_caption("Table 9: Satisfaction with map mode rotation - Full Sample")

# Create Maps df for next section to save inline results

# Full sample

#Play more of
maps_more_df_full <- df %>% select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 0,
                            . == 2 ~ 1, 
                            . ==0 ~ 0)))

#Play less of
maps_less_df_full <- df %>% select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 1,
                            . == 2 ~ 0, 
                            . ==0 ~ 0)))

#Pivot more of
maps_more_df_full <- maps_more_df_full %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(more_of = sum(value, na.rm = TRUE))

#Pivot less of
maps_less_df_full <- maps_less_df_full %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(less_of = sum(value, na.rm = TRUE))

#Merge
mapsdf_full <- left_join(maps_more_df_full, maps_less_df_full, by = "Maps")

#Create difference
mapsdf_full <- mapsdf_full %>% mutate(map_diff = more_of - less_of)

#Colour palette
colour_full <- ifelse(mapsdf_full$map_diff < 0, "#ff9999", "#b3ffb3")

# Admin subsample

#Play more of
maps_more_df_admin <- df %>% filter(admin==1) %>% select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 0,
                            . == 2 ~ 1, 
                            . ==0 ~ 0)))

#Play less of
maps_less_df_admin <- df %>% filter(admin==1) %>% select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 1,
                            . == 2 ~ 0, 
                            . ==0 ~ 0)))

#Pivot more of
maps_more_df_admin <- maps_more_df_admin %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(more_of = sum(value, na.rm = TRUE))

#Pivot less of
maps_less_df_admin <- maps_less_df_admin %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(less_of = sum(value, na.rm = TRUE))

#Merge
mapsdf_admin <- left_join(maps_more_df_admin, maps_less_df_admin, by = "Maps")

#Create difference
mapsdf_admin <- mapsdf_admin %>% mutate(map_diff = more_of - less_of)

#Colour palette
colour_admin <- ifelse(mapsdf_admin$map_diff < 0, "#ff9999", "#b3ffb3")

# Non-admin subsample

#Play more of
maps_more_df_player <- df %>% filter(admin==0) %>%  select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 0,
                            . == 2 ~ 1, 
                            . ==0 ~ 0)))

#Play less of
maps_less_df_player <- df %>% filter(admin==0) %>% select('Anvil':'Yehorivka') %>% 
  mutate(across(c('Anvil':'Yehorivka'), ~
                  case_when(. == 1 ~ 1,
                            . == 2 ~ 0, 
                            . ==0 ~ 0)))

#Pivot more of
maps_more_df_player <- maps_more_df_player %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(more_of = sum(value, na.rm = TRUE))

#Pivot less of
maps_less_df_player <- maps_less_df_player %>% gather(key = "Maps") %>%
    group_by(Maps) %>% dplyr::summarise(less_of = sum(value, na.rm = TRUE))

#Merge
mapsdf_player <- left_join(maps_more_df_player, maps_less_df_player, by = "Maps")

#Create difference
mapsdf_player <- mapsdf_player %>% mutate(map_diff = more_of - less_of)

#Colour palette
colour_player <- ifelse(mapsdf_player$map_diff < 0, "#ff9999", "#b3ffb3")

#Display
table9
Table 9: Satisfaction with map mode rotation - Full Sample

Characteristic

N

Mean (SD)

95% CI

Satisfaction with map rotation2677.02 (2.30)6.7, 7.3
Satisfaction with mode rotation3187.29 (2.11)7.1, 7.5
* N is the observation count. ** CI stands for confidence interval

Given that we cannot satisfy everyone, and since each map and mode choice has an opportunity cost (for each map played, there is some map or mode that someone likes that is not played), an approximate satisfaction rating of 7/10 is not too bad. Different groups also have different opinions about maps and game modes, which we will explore below and in later sections.

Map preferences (Survey Results)

The figures below show the differentials, by map, between people who say we should play more of a certain map and people who say we should play less of that map. The resulting differential can be treated as a “map favourability rating.” Table 10 is divided into three parts - full sample, admins and non-admins. This has been done so that the map preferences of admins, who have a say in what the server plays or votes on, can be compared against those of non-admins.

#Remove previous
rm(table9)

#Sort the mapsdf by difference (Desc)
mapsdf_full_2 <- mapsdf_full %>% arrange(desc(map_diff))
mapsdf_admin_2 <- mapsdf_admin %>% arrange(desc(map_diff))
mapsdf_player_2 <- mapsdf_player %>% arrange(desc(map_diff))

#Slice
c1 <- mapsdf_full_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)
c2 <- mapsdf_admin_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)
c3 <- mapsdf_player_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)

#Sort the mapsdf by difference (Asc)
mapsdf_full_2 <- mapsdf_full %>% arrange(map_diff)
mapsdf_admin_2 <- mapsdf_admin %>% arrange(map_diff)
mapsdf_player_2 <- mapsdf_player %>% arrange(map_diff)

#Slice
c4 <- mapsdf_full_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)
c5 <- mapsdf_admin_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)
c6 <- mapsdf_player_2 %>% select('Maps', 'map_diff') %>%  slice(1:5)

#Merge table
table10_temp <- cbind(c1, c2, c3)
table10_temp2 <- cbind(c4, c5, c6)
table10<- rbind(table10_temp, table10_temp2)

#Remove slices
rm(c1,c2,c3, c4, c5, c6, table10_temp, table10_temp2, mapsdf_full_2, mapsdf_admin_2, mapsdf_player_2)

colnames(table10) <- c('Full sample (FS)', 'Differential (FS)', 'Admins', 
                       'Differential (Adm)', 'Non-Admins', 'Differential (Non-adm)')

#Format
table10<- as_hux(table10) %>% set_bold(1,everywhere) %>% 
  insert_row("Most favourable", "", "", "", "", "", after = 1) %>% set_align(everywhere, everywhere, "center") %>% 
  merge_cells(2, 1:6) %>% set_align(2, everywhere, "center") %>% set_bold(2, everywhere) %>% 
  insert_row("Least favourable", "", "", "", "", "", after = 7) %>% merge_cells(8, 1:6) %>% set_align(8, everywhere, "center") %>% set_bold(8, everywhere) %>% 
  set_background_color(evens, everywhere, "grey95") %>% set_width(1) %>% set_bottom_border(row = 1, col = everywhere) %>% set_caption("Table 10 - Map favourability")

table10
Table 10 - Map favourability
Full sample (FS)Differential (FS)AdminsDifferential (Adm)Non-AdminsDifferential (Non-adm)
Most favourable
Black Coast108Black Coast25Black Coast83
Manic92Manic19Yehorivka74
Narva85Skorpo19Manic73
Harju83Fools18Mutaha71
Yehorivka82Harju16Narva70
Least favourable
Anvil-30Anvil-14Chora-30
Chora-28Lashkar-12Anvil-16
Kamdesh-17Tallil-8Kamdesh-13
Logar-8Goose Bay-7Sumari-11
Sumari-6Kamdesh-4Logar-5

There is a lot of overlap between admins and non-admins. We can see that 2 of the most favourable maps between admins and non-admins are the same (Black Coast, and Manic) and almost everyone dislikes Anvil and Kamdesh!

Anvil and Kamdesh are two of my personal favourites! Its not like we play them very much and people STILL want less of them on net. This makes me very sad.

The charts below show the full results. The first chart (Chart 8A) shows the differentials using the full sample while charts 8B and 8C show the same differentials broken out by admins and non-admins.

The next section compares these findings to what is actually played on the server using data collected from the server logs.

8A: Map favourability (Full sample)

#Remove previous
rm(table10)

#Create diff chart
chart8a <- mapsdf_full %>%  ggplot(aes(x = reorder(Maps, map_diff), y = map_diff)) +
    geom_bar(stat = "identity",
           show.legend = FALSE,
           fill = colour_full,
           color = "white") + 
  geom_hline(yintercept = 0, color = 1, lwd = 0.2) + geom_text(aes(x= Maps, y= map_diff, label = map_diff),  position = position_dodge(width = 1),
    vjust= 0.5, hjust = ifelse(mapsdf_full$map_diff <0, 1, -0.5), size = 3) + ylab("") + xlab("") + scale_y_continuous(limits=c(-30, 120)) +
  coord_flip() + theme_classic() + labs(title = "Chart 8A - Request differential (Full sample) (Most - Least requested)") + theme(plot.title = element_text(hjust = 0.5))

#Display
chart8a

8B: Map favourability (Admins)

#Create diff chart
chart8b <- mapsdf_admin %>%  ggplot(aes(x = reorder(Maps, map_diff), y = map_diff)) +
    geom_bar(stat = "identity",
           show.legend = FALSE,
           fill = colour_admin,
           color = "white") + 
  geom_hline(yintercept = 0, color = 1, lwd = 0.2) + geom_text(aes(x= Maps, y= map_diff, label = map_diff),  position = position_dodge(width = 1),
    vjust= 0.5, hjust = ifelse(mapsdf_admin$map_diff <0, 1, -0.5), size = 3) + ylab("") + xlab("") +
  coord_flip() + theme_classic() + labs(title = "Chart 8B - Request differential (Admins) (Most - Least requested)") + theme(plot.title = element_text(hjust = 0.5))

#Display
chart8b

8C: Map favourability (Non-Admins)

#Create diff chart
chart8c <- mapsdf_player %>%  ggplot(aes(x = reorder(Maps, map_diff), y = map_diff)) +
    geom_bar(stat = "identity",
           show.legend = FALSE,
           fill = colour_player,
           color = "white") + 
  geom_hline(yintercept = 0, color = 1, lwd = 0.2) + geom_text(aes(x= Maps, y= map_diff, label = map_diff),  position = position_dodge(width = 1),
    vjust= 0.5, hjust = ifelse(mapsdf_player$map_diff <0, 1, -0.5), size = 3) + ylab("") + xlab("") +
  coord_flip() + theme_classic() + labs(title = "Chart 8C - Request differential (Non-Admins) (Most - Least requested)") + theme(plot.title = element_text(hjust = 0.5))

#Data prep for maps actual
mpref_full <- mapsdf_full

#Remove previous
rm(list=ls(pattern="colour"), chart8a, chart8b)
rm(list=ls(pattern="maps_more"))
rm(list=ls(pattern="maps_less"))
rm(list=ls(pattern="mapsdf"))

#Display
chart8c

Maps played (Actual data)

In this section, we present actual data from the server regarding the maps that are played using data collected from thousands of rounds played on the server. After filtering out extraneous rounds (training range, skirmish layers, seed layers, map rolls, etc.), we are left with a sample of 4051 rounds covering the period from February to October 2023.

#Remove previous
rm(chart8c)

#Create dataset
dfc9 <- maps_actual %>% select(maps) %>% count(maps, sort = TRUE)
total <- sum(dfc9$n)
title <- paste0("Chart 9: Maps played: Feb - Oct 2023"," (Total matches: ", total, ")")

#Build Chart
chart9 <-  ggplot(
  dfc9, aes(x = reorder(maps, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge") + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 550)) +
  xlab("") +
  ylab("Frequency count") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(position = position_dodge(width= 1), vjust= 0.5, hjust = -0.5, size = 3) +
  coord_flip()

#Display
chart9

In the full sample, we can see that maps played are roughly distributed according to a power law. The long tail is made up of generally unpopular maps, such as Kamdesh, Lashkar, Tallil, etc. The two most popular maps are Mutaha and Narva. Based on the chart above, and Chart 8A, we can get some idea of what maps are the most over or underplayed relative to player preferences:

#Remove previous
rm(chart9)

# Gen pref rank
mpref_full <- mpref_full %>% arrange(desc(map_diff)) %>% 
  mutate(rank_pref = row_number()) 

#Gen played rank
dfc9 <- dfc9 %>% arrange(desc(n)) %>% 
  mutate(rank_actual = row_number()) %>% rename(Maps=maps)

# Merge and rank diff
df10 <- left_join(dfc9, mpref_full, by = 'Maps') %>% 
  select(Maps, rank_actual, rank_pref) %>% 
  mutate(rank_diff = rank_actual - rank_pref)

#Nice colnames
colnames(df10) <- c("Map", "Rank (Actual)", "Rank (Survey)", "Difference")

#Slice
overplayed <- df10 %>% arrange(Difference) %>% slice(1:5)
underplayed <- df10 %>% arrange(desc(Difference)) %>% slice(1:5)

#Merge table
table11<- rbind(overplayed, underplayed)

#Format
table11_fmt <- as_hux(table11) %>% set_bold(1,everywhere) %>% 
  insert_row("Most Overplayed", "", "", "", after = 1) %>% 
  merge_cells(2, 1:4) %>% set_align(2, everywhere, "center") %>% 
  set_bold(2, everywhere) %>% 
  insert_row("Most Underplayed", "", "", "", after = 7) %>% 
  merge_cells(8, 1:4) %>% set_align(8, everywhere, "center") %>% 
  set_bold(8, everywhere) %>% set_background_color(evens, everywhere, "grey95") %>% 
  set_width(1) %>% set_bottom_border(row = 1, col = everywhere) %>% set_caption("Table 11 - Most Over/Underplayed maps")


table11_fmt
Table 11 - Most Over/Underplayed maps
MapRank (Actual)Rank (Survey)Difference
Most Overplayed
Chora522-17
Kokan615-9
Mutaha26-4
Gorodok37-4
Belaya913-4
Most Underplayed
Black Coast1019
Manic1129
Mestia18108
Tallil22166
Skorpo1385

The table above shows the difference in rank between what is played on the server (Chart 9) and what people said they wanted in the survey (Chart 8). According to these results we should probably play less Chora, Kokan, and Mutaha; we should play more Black Coast, Manic, and Mestia.

Over the years, a number of players have complained that TacTrig plays the same maps “all the time.” The charts below show “average repeats per day.” I present two versions:

  • Pre-prime + Prime Time (12PM to 12AM EST)
  • All hours

The reason for the two charts is that including the late night hours will skew the averages, because it is internal policy to stick to safer layers past midnight EST to maximise the duration the server remains populated. Therefore, to gauge variety, we should primarily look at the hours where policy permits more variety.

While people can draw their own conclusions by looking at the charts below, a few things stand out:

  • Outside of late night (past 12AM EST) hours, we do not repeat any map more than twice in a day on average.

  • Only a handful of maps are played every day (average greater than 1) during the main 12 hour window that the server is active.

  • Even if one looks at the full picture which includes all hours, only a couple of maps are played close to twice a day.

Map preferences are varied. Hopefully, the survey results, when measured up against what actually has been played on the server over the course of over 4,000 rounds, give people an idea about what maps we should play more or less of. Outside the top 3 and the bottom 3 maps in Table 11, the survey results line up admirably well against what is actually played.

Daily average (excluding late night EST)

#Remove Previous
rm(dfc9, df10, chart9, mpref_full, overplayed, underplayed, table11, table11_fmt, title, total)

#Create dataset for chart 4

df10a <- maps_actual %>% mutate(lvl = as.factor(maps)) %>% 
  filter(prime==1 | pre_prime==1) %>% 
  select(lvl, day) %>% group_by(day) %>% count(lvl, .drop= FALSE) %>% 
  group_by(lvl) %>%   summarise(mean = round(mean(n), 2))

total <- maps_actual %>% filter(prime==1 | pre_prime==1) %>% nrow()
title <- paste0("Chart 10A: Average per day excluding late night"," (Total matches: ", total, ")")

#Build Chart
chart10a <-  ggplot(
  df10a, aes(x = reorder(lvl, mean), y = mean, label = mean)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 2)) +
  xlab("") +
  ylab("Daily mean") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(position = position_dodge(width= 1), vjust= 0.5, hjust = -0.5, size = 2.75) +
  geom_hline(yintercept = 1 , linetype="dotted", color = "red", size=1) +
  coord_flip()

#Display
chart10a

10B: Daily average (all hours)

#Remove Previous
rm(chart10a, df10a)

#Create dataset for chart 4

df10b <- maps_actual %>% mutate(lvl = as.factor(maps)) %>% 
  select(lvl, day) %>% group_by(day) %>% count(lvl, .drop= FALSE) %>% 
  group_by(lvl) %>%   summarise(mean = round(mean(n), 2))

total <- maps_actual %>% nrow()
title <- paste0("Chart 10B: Average per day - Full sample"," (Total matches: ", total, ")")

#Build Chart
chart10b <-  ggplot(
  df10b, aes(x = reorder(lvl, mean), y = mean, label = mean)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 2.5)) +
  xlab("") +
  ylab("Daily mean") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(position = position_dodge(width= 1), vjust= 0.5, hjust = -0.5, size = 2.75) +
  geom_hline(yintercept = 1 , linetype="dotted", color = "red", size=1) +
  coord_flip()

#Display
chart10b

Modes (Survey Results)

The table below shows the survey responses to the question about game modes:

#Remove previous
rm(df10b, chart10b)

#Table 10 modes
table12 <- df %>%  select('more_inv', 'more_raas', 'more_tc', 'more_fraas', 'more_aas' ) %>% 
  tbl_summary(label =list(more_aas ~ "More AAS",
              more_raas ~ "More RAAS",
              more_fraas ~ "More Fogless RAAS",
              more_inv ~ "More Invasion",
              more_tc ~ "More Territory Control")) %>%add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label =  "What modes should TT play more of?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)

#Apply theme
table12 <- cont_table_fmt(table12) %>% set_caption("Table 12: Mode preferences - Full Sample")

#Display
table12
Table 12: Mode preferences - Full Sample

What modes should TT play more of?

N = 330

95% CI

More Invasion150 (45%)40%, 51%
More RAAS121 (37%)32%, 42%
More Territory Control117 (35%)30%, 41%
More Fogless RAAS111 (34%)29%, 39%
More AAS68 (21%)16%, 25%
* N is the observation count. ** CI stands for confidence interval

In later sections, I examine whether different groups have different preferences as to what game modes we should play

Groups such as Admins versus non-admins, experienced players versus newer players, external clans versus the unaffiliated and so on.

, however, the table above includes the full sample.

The first thing we can see is that no one game mode receives a majority of support by itself. We see some clear preferences, although this does not mean that we should substantially alter the layer mix on the server. As in map decisions, there is an opportunity cost in game modes. If we increase the frequency of any one game mode, then another game mode will see less play time.

The mode that has the greatest number of support is invasion, but even this game mode does not receive majority support. AAS is the least requested game mode. Nonetheless, we will take these preferences into account. We may encourage more frequent appearances of invasion games and some players might even notice this. However, given the results, sweeping changes in our game mode selection are unlikely.

Modes (Actual data)

In this section, we look at what modes are actually played on the server using data from the server logs. This is the same dataset that was used to generate Chart 9. This dataset contains information on 4051 rounds, covering the period from February to October 2023.

#Remove Previous
rm(table12)

#Create chart data
df11 <- maps_actual %>% select(layer, mode, pre_prime, prime, gremlins)

df11$mode <- ordered(df11$mode, levels = c("RAAS", 
                                          "AAS", 
                                          "Invasion", 
                                          "Territory Control",
                                          "Insurgency"))
#Create chart

chart11 <- df11 %>% select(mode) %>% 
  ggplot(aes(x = factor(1), fill = mode)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Game modes", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 11: Game modes (Actual data)") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart11

#Table 13
table13 <- df11 %>% select(mode) %>% 
  tbl_summary(type = mode ~ "categorical",label = mode ~ "Game mode") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Mode') 

#Format

#Copy function with amended footnote
cat_table_fmt_foot <-function(htable) {
  htable %>% as_hux_table() %>%  set_width(1) %>% 
  set_all_padding(4) %>% 
  set_outer_padding(0) %>%
  theme_article() %>% .[-2, ] %>% 
    add_footnote("* N is the observation count. ** Fogless RAAS counted as RAAS") %>%
    set_italic(final(1), 1, TRUE) 
}

#Apply amended format function
table13 <- cat_table_fmt_foot(table13) %>% set_caption("Table 13: Game modes - Actual data")

#Display
table13
Table 13: Game modes - Actual data

Mode

N = 4,051

RAAS2,604 (64%)
AAS1,222 (30%)
Invasion116 (2.9%)
Territory Control109 (2.7%)
Insurgency0 (0%)
* N is the observation count. ** Fogless RAAS counted as RAAS

TT is primarily a RAAS/AAS server. Sadly, the logs do not allow us to differentiate between Fogless RAAS and regular RAAS. Therefore, fogless RAAS is counted as RAAS, and this means that RAAS is probably over-counted since it contains both fogless and regular RAAS.

The most striking result is that when RAAS, fogless (64%) and AAS (30%) are added up, TT is basically a “95-5” server. Invasion tends to be played in Invasion-only servers, while other servers, such as TT, only showcase the game mode once in a while. One compromise solution is that we should aim for a “90-10” (R/AAS-Invasion) dynamic instead of the existing 95-5 dynamic, because this would create more variety in game play.

Trying to aim for a 90-10 dynamic will also go some way towards satisfying the minorities that want more Invasion or Territory control.

See Table 12. To the people that want even more RAAS, I am afraid nothing more can be done for you! To the people that want more AAS or fogless RAAS, this has to come at the expense of RAAS.

Fogless Friday events

As many of you know, we run “Fogless Fridays” as a bi-weekly event on the server. The usual map voting and selection mechanism is suspended in favour of a set rotation during the event. All the maps played during this event are fogless RAAS, AAS, or sometimes lesser played TC/Invasion layers. We asked respondents what they thought of the event:

#Remove previous
rm(table13, cat_table_fmt_foot, title, total, df11, chart11)

#Chart 12
chart12 <- df %>% select(fogless) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = fogless)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Have you heard of Fogless Fridays?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 12: Fogless Friday awareness") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart12

#Remove previous
rm(chart12)

#Table 14 - Fogless Friday awareness
table14 <- df %>% select(fogless) %>% 
  tbl_summary(type = fogless ~ "categorical", 
              label = fogless ~ "Have you heard of Fogless Fridays?", missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "Have you heard of Fogless Fridays?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)


#Apply theme
table14 <- cat_table_fmt(table14) %>% set_caption("Table 14: Fogless Friday Awareness")

#Display
table14
Table 14: Fogless Friday Awareness

Have you heard of Fogless Fridays?

N = 330

95% CI

Yes240 (74%)69%, 79%
No84 (26%)21%, 31%
Did not answer6
* N is the observation count. ** CI stands for confidence interval

We can see that approximately three quarters of respondents are aware of Fogless Fridays. We also asked respondents about whether we should host fewer or more of these events:

#Remove previous
rm(table14)

#Chart 13
chart13 <- df %>% select(fogfreq) %>% drop_na() %>% ggplot(aes(x = factor(1), fill = fogfreq)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("How often should we host Fogless Fridays?", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 13: Fogless Friday frequency") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart13

#Remove previous
rm(chart13)

#Table 15 - Fogless Friday frequency
table15 <- df %>% select(fogfreq) %>% 
  tbl_summary(type = fogfreq ~ "categorical", 
              label = fogfreq ~ "How often should we do Fogless Fridays?", missing_text = "Did not answer") %>% 
  add_ci() %>%   modify_footnote(everything() ~ NA) %>% 
  modify_header(label = "How often should we do Fogless Fridays?") %>% 
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE)


#Apply theme
table15 <- cat_table_fmt(table15) %>% set_caption("Table 15: Fogless Friday frequency")

#Display
table15
Table 15: Fogless Friday frequency

How often should we do Fogless Fridays?

N = 330

95% CI

Never25 (8.2%)5.5%, 12%
Less Often (Monthly)26 (8.5%)5.7%, 12%
As Is (Bi-Weekly)138 (45%)39%, 51%
More Often (Weekly)117 (38%)33%, 44%
Did not answer24
* N is the observation count. ** CI stands for confidence interval

It seems that the plurality of respondents wants to keep things as they are. Taken another way, more than 60% of the server does not want more “Fogless Fridays.” The overwhelming majority of the server does want to continue “Fogless Fridays” in some fashion. The preferences expressed are an endorsement of the current practice of hosting the event bi-weekly.

Rules & Enforcement

This section covers the questions related to rules & enforcement in the survey.

#Remove previous
rm(table15)

#Build
table16 <- df %>% select('basecamp':'asset') %>% tbl_summary(
    type = everything() ~ "continuous", missing = "no",
    statistic = all_continuous() ~ "{mean} ({sd})",
    label = list("basecamp" ~ "Base camping enforcement",
                 "comms" ~ "Comms enforcement",
                 "asset" ~ "Asset claim system"),
      digits = everything() ~ 2) %>% add_ci() %>% 
  modify_header(stat_0 ~ "**Mean (SD)**") %>%   
  modify_footnote(everything() ~ NA) %>%  
  modify_footnote(ci_stat_0 ~ NA, abbreviation = TRUE) %>% add_n()

#Apply theme
table16 <- cont_table_fmt(table16) %>% set_caption("Table 16: Satisfaction with server rules & enforcement  - Full Sample")
#Display
table16
Table 16: Satisfaction with server rules & enforcement - Full Sample

Characteristic

N

Mean (SD)

95% CI

Base camping enforcement3188.08 (2.25)7.8, 8.3
Comms enforcement3188.36 (1.90)8.1, 8.6
Asset claim system3228.63 (2.25)8.4, 8.9
* N is the observation count. ** CI stands for confidence interval

The density plots below illustrate the frequency distributions of the rules & enforcement measures:

#Remove previous
rm(table16)

#Pivot the category scores to long
tempdf <- df %>% select(basecamp, comms, asset) %>% 
  tidyr::pivot_longer(cols = c("basecamp", "comms", "asset"), 
                                   names_to = "Category", values_to = "Score")

tempdf$Category <- ordered(tempdf$Category, levels = c("basecamp", "comms", "asset"))
levels(tempdf$Category) <- c("Base camping enforcement", "Comms enforcement", "Asset claim")

# Create faceted density plots
chart14 <- ggplot(data = tempdf, aes(x=Score)) + geom_density(color="#99ccff", fill="#99ccff") + theme_classic() + xlab("") + ylab("Density") + 
  labs(title = "Chart 14: Density plots of server rules section") + theme(plot.title = element_text(hjust = 0.5))

design <- c("AABB
            AACC") 

#display
chart14 + ggh4x::facet_manual(~Category, scales = "free_y", design=design)

The density plots in Chart 14 above show no pronounced “peaks” at lower scores. We can see that people are most happy with our asset claim system, but there is a small “bump” at the score of 5 in the base camping plot. This suggests that there is a small cohort (between 5-10%) of respondents that say they are unhappy with base camping enforcement. Anecdotally, by reading their comments, it seems that they are not unhappy with our base camping enforcement as much as they are about the fact that we disallow base camping to begin with.

Regardless, the scores for base camping enforcement, comms enforcement, and asset claim system are all between 8-8.5/10 suggesting that most people are generally satisfied with all three aspects.

ICO & Round Duration

This section is not about survey results but asks and answers some important questions in the public interest regarding Squad’s recent infantry combat overhaul (the “ICO”). The ICO has been a divisive update within Squad and within the TT community as well. One common complaint centres around a perception that the game has slowed down too much and become a passive slog. This, the detractors claim, has led to “long and boring matches.”

Data from multiple servers was used in this section. From TT, I used the the same dataset used to generate Chart 9 which covers 4051 rounds during the period from February 2023 to October 2023. Other servers that provided data include:

  • Unnamed (UNN): 5913 valid rounds covering January to November 2023.

  • Kill them first (KTF): 4727 valid rounds covering August 2022 to November 2023.

  • Show of force (SOF): 278 valid rounds starting on October 24th, 2023. SOF is a relatively new server which was established post-ICO and hence has no data preceding the ICO.

The same process which was used to “clean” the TT data was applied to data from the other servers. We filtered out map rolls, training range time, and seed layers, and other irrelevant matches. Using this combined dataset, we can get some insight into how the ICO has changed round durations.

The table below only considers TT:

#Remove previous
rm(chart14)

#Build table
#overall
overall <- maps_actual%>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'Full sample (Feb - Oct 23)'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# Post 4.3 RAAS bleed
postbleed <- maps_actual %>% filter(post_bleed=="Yes" & mode=="RAAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'RAAS/Fogless Only - Post 4.3 RAAS bleed changes'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# AAS only
aas <- maps_actual %>% filter(mode=="AAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'AAS Only'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>%  as_hux_table()

# RAAS/FRAAS only
raas <- maps_actual %>% filter(mode=="RAAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'RAAS/Fogless Only'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# Big maps only
bigmap <- maps_actual %>% filter(big_map==1) %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'Big maps only***'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()


#Create table
dur_tab <- rbind(overall, raas, aas, postbleed, bigmap) %>% filter(row_number() %% 2 == 0) %>% 
  insert_row("Sample", "Post-ICO Duration (N)*", "Pre-ICO Duration (N)*", "Difference", "p-value**", after = 0) %>% 
  set_bold(1, everywhere) %>% theme_article() %>% set_bottom_border(1, everywhere, value = 0.4) %>% 
  set_background_color(2:6, 5, "#6aa84f") %>% set_text_color(2:6, 5, "white") %>% 
  set_width(1) %>% set_caption("Table 17A - Round durations pre & post ICO at TT") %>% 
  add_footnote("* Duration rounded to nearest minute. N is the observation count. ** P-values calculated using Welch's T-test. P-values in green are significant at the 99% level.") %>% 
  add_footnote("*** Big maps include Anvil, Black Coast, Goose Bay, Gorodok, Harju, Kohat, Lashkar, Manic, Skorpo, Tallil and Yehorivka") %>% 
  set_italic(final(2), 1, TRUE) 
  

dur_tab
Table 17A - Round durations pre & post ICO at TT
SamplePost-ICO Duration (N)*Pre-ICO Duration (N)*Differencep-value**
Full sample (Feb - Oct 23)52 (313)43 (3,738)8.9<0.001
RAAS/Fogless Only53 (229)44 (2,375)8.2<0.001
AAS Only50 (58)40 (1,164)10<0.001
RAAS/Fogless Only - Post 4.3 RAAS bleed changes53 (229)45 (2,114)8.0<0.001
Big maps only***57 (141)48 (1,466)8.9<0.001
* Duration rounded to nearest minute. N is the observation count. ** P-values calculated using Welch's T-test. P-values in green are significant at the 99% level.
*** Big maps include Anvil, Black Coast, Goose Bay, Gorodok, Harju, Kohat, Lashkar, Manic, Skorpo, Tallil and Yehorivka

Table 17A above shows that round times have indeed increased after the ICO update at TT. The increase is statistically significant but, no matter how one slices the data (AAS only, RAAS only, etc.), the difference works out to be somewhere between 8 and 10 minutes per round at TT. Before the ICO, round times were somewhere between 40 and 50 minutes. The ICO has resulted in rounds being somewhere between 50 minutes and 1 hour on average.

Interestingly, the RAAS bleed changes introduced in version 4.3 (March 2023) did not really increase average RAAS round lengths - average round lengths remained at around 45 minutes. See Pre-ICO durations of RAAS/Fogless only with and without the pre 4.3 rounds included (rows 2 and 4 of Table 17A.

Next, let us look at the three other servers which provided the same data:

#Remove previous
rm(dur_tab, overall, postbleed, raas, aas, bigmap)

#Build table
#overall
overall <- maps_actual_all %>% filter(server!="TT") %>%  select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'Full sample (KTF, UNN, & SOF)'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# Post 4.3 RAAS bleed
postbleed <- maps_actual_all %>% filter(server!="TT") %>% filter(post_bleed=="Yes" & mode=="RAAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'RAAS/Fogless Only - Post 4.3 RAAS bleed changes'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# AAS only
aas <- maps_actual_all %>% filter(server!="TT") %>% filter(mode=="AAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'AAS Only'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>%  as_hux_table()

# RAAS/FRAAS only
raas <- maps_actual_all %>% filter(server!="TT") %>% filter(mode=="RAAS") %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'RAAS/Fogless Only'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()

# Big maps only
bigmap <- maps_actual_all %>% filter(server!="TT") %>% filter(big_map==1) %>% select(dur_min, post_ico) %>% tbl_summary(by= 'post_ico',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean} ({N_obs})",
  missing = "no", label = list('dur_min' ~ 'Big maps only***'),
  digits = everything() ~ 0) %>% add_difference() %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% as_hux_table()


#Create table
dur_tab <- rbind(overall, raas, aas, postbleed, bigmap) %>% filter(row_number() %% 2 == 0) %>% 
  insert_row("Sample", "Post-ICO Duration (N)*", "Pre-ICO Duration (N)*", "Difference", "p-value**", after = 0) %>% 
  set_bold(1, everywhere) %>% theme_article() %>% set_bottom_border(1, everywhere, value = 0.4) %>% 
  set_background_color(2:3, 5, "#6aa84f") %>% set_text_color(2:3, 5, "white") %>% 
  set_background_color(5:6, 5, "#6aa84f") %>% set_text_color(5:6, 5, "white") %>% 
  set_width(1) %>% set_caption("Table 17B - Round durations pre & post ICO - Non TT (UNN, KTF, SOF)") %>% 
  add_footnote("* Duration rounded to nearest minute. N is the observation count. ** P-value calculated using Welch's T-test. P-values in green are significant at the 99% level.") %>% 
  add_footnote("*** Big maps include Anvil, Black Coast, Goose Bay, Gorodok, Harju, Kohat, Lashkar, Manic, Skorpo, Tallil and Yehorivka") %>% 
  set_italic(final(2), 1, TRUE) 
  

dur_tab
Table 17B - Round durations pre & post ICO - Non TT (UNN, KTF, SOF)
SamplePost-ICO Duration (N)*Pre-ICO Duration (N)*Differencep-value**
Full sample (KTF, UNN, & SOF)54 (2,155)51 (8,763)3.1<0.001
RAAS/Fogless Only53 (1,099)50 (6,496)2.3<0.001
AAS Only49 (120)47 (382)2.00.3
RAAS/Fogless Only - Post 4.3 RAAS bleed changes53 (1,099)51 (4,828)2.0<0.001
Big maps only***55 (1,234)53 (5,123)1.50.004
* Duration rounded to nearest minute. N is the observation count. ** P-value calculated using Welch's T-test. P-values in green are significant at the 99% level.
*** Big maps include Anvil, Black Coast, Goose Bay, Gorodok, Harju, Kohat, Lashkar, Manic, Skorpo, Tallil and Yehorivka

The results are quite different for the other servers. The increases post-ICO are generally less pronounced than they have been at TT. The increases have been in the range of 1 and 3 minutes, with the exception of AAS rounds which are statistically identical in duration pre and post-ICO. It is interesting to note that AAS is the game mode that has seen the greatest increase in duration for TT, but almost no change in the other servers.

The biggest takeaways from Tables 17A & 17B is that the ICO affected round times at TT thrice as much as it did on other servers we have data for:

\[ Total\ ICO\ effect\ = \frac{Difference}{Pre-ICO \ Average}\ \ \ \ \ TT\ \to \frac{8.9}{43} \approx 21 \% \ \ \ \ \ Other \ Servers\to \frac{3.1}{51} \approx 6 \% \]

Round times at TT increased by around 21%, while round times at other servers increased by around 6%. Some may say that the difference is driven by a different game mode mix across the servers. Perhaps the other servers play a higher proportion of Invasion rounds, which can sometimes produce long games. We can do the same calculation above for RAAS only with a similar outcome. RAAS durations at TT increased by approximately 19%, while the corresponding figure for the other servers was approximately 5%. Readers can verify this themselves by checking the RAAS rows in Tables 17A & 17B.

The main factor driving the differential impact of the ICO is that the TT’s rounds were much shorter than rounds on other servers pre-ICO. However, post ICO, rounds at TT have been about as long as other servers. This could be for several reasons:

  • There has been an influx of newer players post-ICO to all servers which has resulted in convergence. See Appendix.

  • Experienced/disgruntled players are playing less Squad which has led to TT converging towards other servers.

  • The ICO’s mechanics have indeed simply slowed down experienced players who tend to play “faster” (i.e., rotate and react quicker, rush objectives in RAAS/AAS, etc.).

My best guess remains that it is a combination of all three factors. As we get deeper into the ICO and get more data, we can re-do the analysis to verify or reject these hypotheses. It is too soon to say what the long-term impact of the ICO will be on both TT and other servers.

Looking only at means, as we do above, hides many characteristics of the underlying distributions. While we could present more information about the distribution in addition to the mean (for example, the standard deviation, skewness, etc.), it is better to simply show the distributions:

#Remove previous
rm(dur_tab, overall, postbleed, raas, aas, bigmap)

# Create faceted density plots
chart15a <- maps_actual %>% ggplot(aes(x=dur_min)) + geom_density(aes(fill = post_ico), alpha = 0.5, colour = NA) + scale_fill_manual(values = c("#ff0000", "#0000FF")) + theme_classic() + theme(legend.position = "bottom") + labs(fill = "") + ylab("Density") + xlab(" ") +
  labs(title = "Chart 15A: Round durations at TT") + theme(plot.title = element_text(hjust = 0.5))

# Create faceted density plots
chart15b <- maps_actual_all %>% filter(server!="TT") %>%  ggplot(aes(x=dur_min)) + geom_density(aes(fill = post_ico), alpha = 0.5, colour = NA) + scale_fill_manual(values = c("#ff0000", "#0000FF")) + theme_classic() + theme(legend.position = "bottom") + labs(fill = "") + ylab("Density") + xlab(" ") +
  labs(title = "Chart 15B: Round durations at other servers") + theme(plot.title = element_text(hjust = 0.5))

#Display
chart15a
chart15b

Immediately we can see that the distribution of round times has shifted to the right after the ICO for both TT and other servers. The post ICO distribution also has a “fatter tail.”

The statistical term ‘fat tails’ refers to probability distributions with relatively high probability of extreme outcomes.

We can see that for other servers, the tail extends further out, implying more long rounds. The shift to the right is also much less pronounced than it is at TT. This is in line with the result that round durations have shifted more at TT than at other servers post ICO.

The next chart is a violin plot and shows the pre and post ICO differences in round duration broken out by server:

#Remove previous
rm(chart15a, chart15b)

#TT median, p75, p25 pre_ico

tt_pre_ico <- maps_actual %>% filter(post_ico =="Pre-ICO")

tt_median <- median(tt_pre_ico$dur_min)
tt_p25 <- quantile(tt_pre_ico$dur_min, probs = 0.25)
tt_p75 <- quantile(tt_pre_ico$dur_min, probs = 0.75) 

#Pre ICO
chart15c <- maps_actual_all %>% filter(post_ico =="Pre-ICO") %>% ggplot(aes(x = factor(server), y = dur_min, fill = factor(server))) +
  geom_violin(scale = "width", trim = FALSE, alpha = 0.7, colour=NA) +
  geom_boxplot(width = 0.1, fill = "white", alpha = 0.9, outlier.shape = NA) +
  scale_fill_manual(values = c("#F8766D", "#00BA38", "#619CFF", "#ffff4d")) +
  xlab("") +
  ylab("Round duration (Min)") +
  ggtitle("Chart15C: Pre-ICO Distributions by server") +
  geom_hline(yintercept = tt_median , linetype="dotted", color = "black", size=1) +
  geom_hline(yintercept = tt_p25 , linetype="dotted", color = "red", size=1) +
  geom_hline(yintercept = tt_p75 , linetype="dotted", color = "red", size=1) +
  theme_classic() +  
  theme(plot.title = element_text(hjust = 0.5)) + 
  theme(legend.position = "none")

#Post ICO
chart15d <- maps_actual_all %>% filter(post_ico =="Post ICO") %>% ggplot(aes(x = factor(server), y = dur_min, fill = factor(server))) +
  geom_violin(scale = "width", trim = FALSE, alpha = 0.7, colour=NA) +
  geom_boxplot(width = 0.1, fill = "white", alpha = 0.9, outlier.shape = NA) +
  scale_fill_manual(values = c("#F8766D", "#00BA38", "#619CFF", "#ffff4d")) +
  xlab("") +
  ylab("Round duration (Min)") +
  ggtitle("Chart15D: Post ICO Distributions by server") +
  theme_classic() +  
  theme(plot.title = element_text(hjust = 0.5)) + 
  theme(legend.position = "none")

#Display
chart15c
chart15d

#maps_actual_all %>% filter(post_ico == "Post ICO") %>% select(dur_min, server) %>% aov(dur_min ~ server, data =.) %>% TukeyHSD()

In Chart 15C above, we can see that the distribution for TT is much “tighter” around its mean and more normally distributed pre-ICO. There are hardly any rounds which extend past 75 minutes. At the two other servers (SOF did not exist pre-ICO), we see a far more diverse set of round durations. The longer “boxes” and “whiskers” indicate higher a higher variance in round durations. The horizontal lines show the TT’s median round length (in black) and the interquartile range (in red). We can see that the median of the two other servers is near the 75th percentile of TT’s pre-ICO round duration showing that pre-ICO TT did really have shorter rounds, on average, when compared to other servers.

Chart 15D shows the situation post-ICO. We can see that the average round durations have become more similar (see the “median” line in the box plots). TT still has fewer “tail” rounds that go beyond 75 minutes and a tighter distribution (lower variance) overall.

The table below compares the proportion of rounds that went over an hour before and after the ICO:

#Remove previous
rm(chart15c, chart15d, tt_median, tt_p25, tt_p75, tt_pre_ico)

#Build
#TT
table18a <- maps_actual %>% select('dur_buck', 'post_ico') %>% tbl_cross(
  row = 'dur_buck', col = 'post_ico', percent = "column", margin='row',
  label = list('dur_buck' ~ 'Round Durations (TT)')) %>%  
  add_p('chisq.test') %>% modify_footnote(everything() ~ NA)

#Other
table18b <- maps_actual_all %>% filter(server!="TT") %>%  select('dur_buck', 'post_ico') %>% tbl_cross(
  row = 'dur_buck', col = 'post_ico', percent = "column", margin='row',
  label = list('dur_buck' ~ 'Round Durations (Other servers)')) %>%  
  add_p('chisq.test') %>% modify_footnote(everything() ~ NA)


#Stack
table18<- tbl_stack(list(table18a, table18b)) %>% as_hux_table()

#Format
table18 <- table18 %>% slice(2:14) %>%  set_bold(c(1,2,8), everywhere) %>% theme_article() %>% 
  set_background_color(c(2,8), everywhere, "#6aa84f") %>% 
  set_text_color(c(2,8), everywhere, "white") %>% 
  add_footnote("P-values calculated using Pearson's Chi-squared test. Significant at the 99% level.") %>% 
  set_italic(final(1), 1, TRUE) %>% set_width(1) %>% set_caption("Table 18 - Duration categories pre & post ICO")

#Display
table18
Table 18 - Duration categories pre & post ICO

Post ICO

Pre-ICO

p-value

Round Durations (TT)<0.001
Below 20 min0 (0%)11 (0.3%)
Between 20 and 40 min50 (16%)1,547 (41%)
Between 40 and 60 min196 (63%)1,963 (53%)
60+ min67 (21%)217 (5.8%)
Total313 (100%)3,738 (100%)
Round Durations (Other servers)<0.001
Below 20 min15 (0.7%)66 (0.8%)
Between 20 and 40 min452 (21%)1,978 (23%)
Between 40 and 60 min1,036 (48%)4,849 (55%)
60+ min652 (30%)1,870 (21%)
Total2,155 (100%)8,763 (100%)
P-values calculated using Pearson's Chi-squared test. Significant at the 99% level.

The key takeaway, for TT, from Table 18 above is that the proportions of rounds that fall within each “duration bucket” is indeed radically different. Before the ICO, we can see that roughly 41% of rounds fell between 20 and 40 minutes at TT. Post-ICO, the number of rounds that lasted between 20 and 40 minutes has less than halved and stands at 16%. On the other end of the spectrum, we can see that the proportion of rounds that last more than 60 minutes has more than tripled! Before the ICO, the number of rounds that lasted more than 60 minutes was approximately 6% at TT. After the ICO, this figure has more than tripled to 21%.

For other servers, we can see that rounds which last more than 60 minutes have increased, but not by the same amount. In fact, other servers had a similar proportion of rounds that lasted more than 60 minutes pre-ICO as TT does post ICO (21%).

Players at TT are thrice as likely to encounter hour-long rounds after the ICO. The proportion of “quick rounds” has been cut in half. Together, these changes in duration contribute to the feeling that many players have about the game becoming too “slow and boring.” It is probably fair to say that the average increase of 8 minutes per round shown in Table 17A captures only part of the story. One has to look more closely at the underlying distributions, as we have done above, to understand what is driving player perceptions about round lengths at TT. The change has not been as pronounced on other servers.

I leave it to the reader to decide for themselves whether these changes are positive or negative. All we can say for certain is that these changes are real.

Well, at least with 99% confidence!

Subgroup Analysis

Next, I compare various subgroups of respondents to see whether their opinions differ from others not part of the subgroup. The subgroups compared are:

  • Admins versus non-Admins.
  • Experienced players (1,000+ hours) versus newer players.
  • External clan members versus unaffiliated players.
  • Survey 4 results (i.e., this survey) versus Survey 3 results.

I did look at other subgroups such as US EST time zone against non-EST, as well as long-term (1+ year) TT players against newer arrivals, but did not find any interesting differences. The tables below make liberal use of statistical tests and these need to be explained.

For each subgroup, there are two tables. The first table looks at continuous (0-10) score variables such as admin professionalism, teamplay, etc. The second table looks at dichotomous variables which are binary “Yes/No” questions such as “Do you want more RAAS?” The two blue boxes below describe how to read each type of table:

Here’s how to read tables comparing continuous variables (Tables 19A - 22A)

  • Rows highlighted in dark red/green show strong statistical differences between the two groups (i.e., both P and Q values are below 0.05).

  • Rows highlighted in light red/green show weak statistical differences between the two groups (i.e., only the P-value is below 0.05).

  • Rows without colour should be treated as not statistically significant (i.e., any difference is statistically indistinguishable from zero).

  • Statistically significant simply means that the difference between the two groups is non-zero. It is not a statement about the magnitude of the difference.

Here’s how to read tables comparing dichotomous variables (Tables 19B - 22B)

  • Percentages shown in the table refer to what percentage of respondents answered “Yes” to the particular questions. For example, in Table 17B, 58% of non-admins have over 1,000 hours in Squad and so on.

  • Rows highlighted in green show statistically significant differences between the two groups (i.e., P-value below 0.05), and the subgroup percentage is higher than the comparison percentage.

  • Rows highlighted in red show statistically significant differences between the two groups (i.e., P-value below 0.05), and the subgroup percentage is lower than the comparison percentage.

  • Rows without colour should be treated as not statistically significant (i.e., any difference is statistically indistinguishable from zero).

  • Statistically significant simply means that the difference between the two groups (Admins and Non-Admins) is non-zero. It is not a statement about the magnitude of the difference.

Admins versus non-admins

This section compares the scores of admins and non-admins across the various questions in the survey. The aim is to check for statistical differences.

#Remove previous
rm(table18, table18a, table18b)

#Build
diff_table <- df %>% select(admresp, admfair, admoverall,
                            balance, asset, skill, slserver, admprof, twork, learn, toxic, maprot, moderot, basecamp, comms, admin) %>% tbl_summary(by= 'admin',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean}",
  missing = "no", label = list('admresp' ~ 'Admin response time',
                               'admprof' ~ 'Admin professionalism',
                               'admfair' ~ 'Admin fairness',
                               'admoverall' ~ 'Overall admin quality',
                               "twork" ~ "Teamwork",
                               "learn" ~ "Learning Environment",
                               "balance" ~ "Team Balance",
                               "skill" ~ "Gameplay level",
                               "slserver" ~ "Environment for SLs",
                               "toxic" ~ "Overall server environment",
                               'maprot' ~ "Satisfaction with map rotation",
                               'moderot' ~ "Satisfaction with mode rotation",
                               "basecamp" ~ "Base camping enforcement",
                               "comms" ~ "Comms enforcement",
                               "asset" ~ "Asset claim system"),
  digits = everything() ~ 2) %>% add_difference() %>%  add_q(method = "bonferroni") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% 
  modify_header(stat_1 = "Non-Admin (N={n})", stat_2="Admin (N={n})", label = "Continuous (0-10) Variables") %>%  as_hux_table()

  
#Format
table19a <- diff_table_fmt(diff_table) %>%  set_caption("Table 19A - Admins versus non-Admins (Continuous variables)")

#Display
table19a
Table 19A - Admins versus non-Admins (Continuous variables)

Continuous (0-10) Variables

Non-Admin (N=264)

Admin (N=66)

Difference

p-value

q-value

Admin response time8.298.94-0.65<0.0010.004
Admin fairness8.218.89-0.68<0.0010.004
Overall admin quality8.499.17-0.68<0.0010.002
Team Balance6.207.57-1.4<0.001<0.001
Asset claim system8.459.32-0.87<0.0010.007
Gameplay level8.388.95-0.570.0040.067
Environment for SLs7.528.00-0.480.0490.7
Admin professionalism8.188.50-0.320.081>0.9
Teamwork8.058.45-0.400.0550.8
Learning Environment6.386.62-0.240.5>0.9
Overall server environment7.537.520.01>0.9>0.9
Satisfaction with map rotation6.897.46-0.560.084>0.9
Satisfaction with mode rotation7.297.290.00>0.9>0.9
Base camping enforcement8.068.16-0.100.7>0.9
Comms enforcement8.298.62-0.330.2>0.9
* P-value calculated using Welch's T-test. ** Q-value calculated using Bonferroni's correction

Key results from table 19A:

Check how to read the table above here

Unsurprisingly, admins tend to hold more positive views about the server than non-admins.

  • Strong statistical differences in admin response time, admin fairness, overall admin quality, balance, and the asset claim system.

  • Weak statistical differences in the gameplay level and the environment for SL categories.

While there are some areas of both strong and weak statistical differences between admins and non-admins, most of these disparities are not material. For example, across all the admin questions, both admins and non-admins rate us above 8/10. While it is interesting to note that non-admins rate us approximately 8.5/10 when it comes to overall admin quality, and admins rate us slightly higher above 9/10 in this category, these differences are merely academic.

The only material difference is in team balance. As noted earlier, we do have internal guidelines about addressing team balance. What players may not see is the amount of discussion about balance in admin chat. Admins are constantly keeping an eye on balance and therefore, it is unsurprising that they rate us more highly than non-admins on this question. While empirically, we do stay within our balance guidelines the vast majority of the time, the admins should make an effort to broadcast when we make balance corrections. I believe players would benefit if they had more indicators that that admins have our eye on the ball in real time.

Moving on, I look at various dichotomous (Yes/No) questions and compare admins to non-admins:

#Remove previous
rm(diff_table, table19a)

#Build
dich_table <- df %>% select('over1k', 'over1tt', 'sl',  'more_raas', 'more_fraas', 'more_tc', 'more_aas','more_inv', 'admin') %>% tbl_summary(by= 'admin',
  type = everything() ~ "dichotomous", statistic = all_dichotomous() ~ "{p}%",
  missing = "no", label = list('over1k' ~ 'Over 1,000 hours?',
                               'over1tt' ~ 'Over 1 year at TT?',
                               'sl' ~ 'Do you SL open squads often?',
                               'more_raas' ~ 'Want more RAAS',
                               'more_aas' ~ 'Want more AAS',
                               'more_fraas' ~ 'Want more fogless RAAS',
                               'more_tc' ~ 'Want more Territory Control',
                               'more_inv' ~ 'Want more Invasion'),
  digits = everything() ~ 0) %>% add_p(test = everything() ~ "chisq.test") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_header(stat_1 = "Non-Admin (N={n})", stat_2="Admin (N={n})", label = "Dichotomous (Y/N) Variables") %>% as_hux_table()

#Format
table19b <- dich_table_fmt(dich_table) %>% set_caption("Table 19B - Admins versus non-Admins (Dichotomous variables)") %>% insert_row("Mode Preferences", "", "", "", after = 4) %>% merge_cells(5, everywhere) %>% merge_cells(5, everywhere) %>% set_align(5, everywhere, "left") %>% set_bold(5, everywhere)

bottom_border(table19b)[5, ] <- brdr(1, "solid", "black")
top_border(table19b)[5, ] <- brdr(1, "solid", "black")

#Display
table19b
Table 19B - Admins versus non-Admins (Dichotomous variables)

Dichotomous (Y/N) Variables

Non-Admin (N=264)

Admin (N=66)

p-value

Over 1,000 hours?53%85%<0.001
Over 1 year at TT?62%94%<0.001
Do you SL open squads often?36%35%>0.9
Mode Preferences
Want more RAAS41%18%<0.001
Want more fogless RAAS29%52%<0.001
Want more Territory Control31%53%0.001
Want more AAS19%29%0.10
Want more Invasion46%42%0.7
* P-value calculated using Pearson's Chi-squared test

Key results from table 19B:

Check how to read the table above here

  • Unsurprisingly, a significantly higher proportion of Admins have over 1,000 hours in game and have spent more than 1-year at TT compared to non-Admins.

  • Non-admins want more RAAS (though, fewer than 50% of them), while admins want more fogless RAAS and territory control.

A small majority of admins want more fogless RAAS while a plurality of non-Admins want more regular RAAS. We know from Chart 11/Table 13 that TT is mostly an RAAS and AAS server. Unfortunately, the data do not allow us to differentiate fogless RAAS from regular RAAS so we do not have a good idea of the actual mix in the server. Anecdotally, fogless RAAS is quite rare and a little more of it would probably be desirable.

We can see from Table 20B below that the preference for more fogless RAAS is also shared by experienced players.

Of course, there is overlap between admins and experienced players. Most admins are also experienced players. However, the majority of experienced players who responded to the survey are not admins.

A small majority of admins also want more territory control, however, this opinion appears to be rare among non-Admins.

Experienced vs newer players

According to Chart 1/Table 1, a sizeable majority of survey respondents have over 1,000 hours in Squad. We want to check whether these relatively more experienced players hold different views from people who are newer to the game.

#Remove previous
rm(dich_table, table19b)

#Build
diff_table <- df %>% select(toxic, admprof, admresp, admfair, admoverall, twork, learn, balance, skill, maprot, moderot, slserver, basecamp, comms, asset, over1k) %>% tbl_summary(by= 'over1k',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean}",
  missing = "no", label = list('admresp' ~ 'Admin response time',
                               'admprof' ~ 'Admin professionalism',
                               'admfair' ~ 'Admin fairness',
                               'admoverall' ~ 'Overall admin quality',
                               "twork" ~ "Teamwork",
                               "learn" ~ "Learning Environment",
                               "balance" ~ "Team Balance",
                               "skill" ~ "Gameplay level",
                               "slserver" ~ "Environment for SLs",
                               "toxic" ~ "Overall server environment",
                               'maprot' ~ "Satisfaction with map rotation",
                               'moderot' ~ "Satisfaction with mode rotation",
                               "basecamp" ~ "Base camping enforcement",
                               "comms" ~ "Comms enforcement",
                               "asset" ~ "Asset claim system"),
  digits = everything() ~ 2) %>% add_difference() %>%  add_q(method = "bonferroni") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% 
  modify_header(stat_1 = "1,000+ hours (N={n})", stat_2="< 1,000 hours (N={n})", label = "Continuous (0-10) Variables") %>%  as_hux_table()

  
#Format
table20a <- diff_table_fmt(diff_table) %>%  set_caption("Table 20A - Experienced vs Newer players (Continious variables)")

#Display
table20a
Table 20A - Experienced vs Newer players (Continious variables)

Continuous (0-10) Variables

1,000+ hours (N=195)

< 1,000 hours (N=135)

Difference

p-value

q-value

Overall server environment7.257.92-0.670.0030.044
Admin professionalism8.038.57-0.540.0040.065
Admin response time8.488.330.150.4>0.9
Admin fairness8.248.52-0.280.14>0.9
Overall admin quality8.538.76-0.230.2>0.9
Teamwork8.078.21-0.140.4>0.9
Learning Environment6.276.65-0.380.2>0.9
Team Balance6.626.260.360.14>0.9
Gameplay level8.588.380.200.3>0.9
Satisfaction with map rotation6.897.21-0.310.3>0.9
Satisfaction with mode rotation7.157.51-0.360.12>0.9
Environment for SLs7.677.530.140.5>0.9
Base camping enforcement7.898.35-0.460.062>0.9
Comms enforcement8.248.52-0.280.2>0.9
Asset claim system8.798.390.390.13>0.9
* P-value calculated using Welch's T-test. ** Q-value calculated using Bonferroni's correction

Key results from table 20A:

Check how to read the table above here

  • On most dimensions, there is no statistical difference between experienced and newer players.

  • Some differences in scores given in the categories of admin professionalism and overall server environment between experienced and newer players. In both cases, experienced players rated us slightly lower.

While there are some differences in the categories mentioned above, these differences are statistical, not material. For example, on admin professionalism, both experienced players and newer players rate us in the neighbourhood of 8/10. Why experienced players rate us lower on the overall server environment is a small mystery. However, the difference here is slight as both groups rate us in the neighbourhood of 7/10 on this question.

Table 20B below examines differences between experienced and newer players on dichotomous (Yes/No) type questions.

#Remove previous
rm(diff_table, table20a)

#Build
dich_table <- df %>%  select('over1k', 'over1tt', 'sl', 'more_raas':'more_inv') %>% tbl_summary(by= 'over1k',
  type = everything() ~ "dichotomous", statistic = all_dichotomous() ~ "{p}%",
  missing = "no", label = list('over1tt' ~ 'Over 1 year at TT?',
                               'sl' ~ 'Do you SL open squads often?',
                               'more_raas' ~ 'Want more RAAS',
                               'more_aas' ~ 'Want more AAS',
                               'more_fraas' ~ 'Want more fogless RAAS',
                               'more_tc' ~ 'Want more Territory Control',
                               'more_inv' ~ 'Want more Invasion'),
  digits = everything() ~ 0) %>% add_p(test = everything() ~ "chisq.test") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_header(stat_1 = "1,000+ hours (N={n})", stat_2="< 1,000 hours (N={n})", label = "Dichotomous (Y/N) Variables") %>% as_hux_table()

#Format
table20b <- dich_table_fmt(dich_table) %>% set_background_color(5,1:4, "#6aa84f") %>% set_caption("Table 20B - Experienced vs Newer players (Dichotomous variables)") %>% insert_row("Mode Preferences", "", "", "", after = 3) %>% merge_cells(4, everywhere) %>% merge_cells(4, everywhere) %>% set_align(4, everywhere, "left") %>% set_bold(4, everywhere)

bottom_border(table20b)[4, ] <- brdr(1, "solid", "black")
top_border(table20b)[4, ] <- brdr(1, "solid", "black")

# For the life of me I CAN'T FIGURE OUT WHY MY CONDITIONAL FORMATTING FUNCTION DID NOT TAKE CARE OF THIS AUTOMATICALLY. I FUCKING SPENT A FUCKING HOUR TRYING TO DO THIS VIA FUNCTION BUT JUST HAD TO DO IT MANUALLY. IF SOMEONE CAN FIGURE OUT WHY THE FUNCTION CALLED DICH_TABLE_FMT THAT I MADE IN THE FIRST CODE CHUNK FAILS TO DEAL WITH THIS PARTICULAR CASE PLEASE LET ME KNOW.

#Display
table20b
Table 20B - Experienced vs Newer players (Dichotomous variables)

Dichotomous (Y/N) Variables

1,000+ hours (N=195)

< 1,000 hours (N=135)

p-value

Over 1 year at TT?84%47%<0.001
Do you SL open squads often?39%32%0.2
Mode Preferences
Want more RAAS30%47%0.003
Want more AAS29%9%<0.001
Want more fogless RAAS42%21%<0.001
Want more Territory Control36%34%0.7
Want more Invasion41%52%0.067
* P-value calculated using Pearson's Chi-squared test

Key results from table 20B:

Check how to read the table above here

  • Experienced players are far more likely to have spent over a year at TT.

  • 46% of newer players want more RAAS as opposed to only 30% experienced players.

  • Experienced players want more AAS and fogless RAAS than newer players, but the majority of both experienced and newer players do not want more AAS or fogless RAAS.

Clans

As we saw in Chart 3/Table 4, a significant minority of TT’s player base belong to external clans. The tables below examine whether this minority holds views that are different from the unaffiliated majority.

#Remove previous
rm(dich_table, table20b)

#Build
diff_table <- df %>% select(basecamp, twork, balance, maprot, slserver, comms, asset, 'admresp':'admoverall', learn, skill, toxic, moderot, clan) %>% tbl_summary(by= 'clan',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean}",
  missing = "no", label = list('admresp' ~ 'Admin response time',
                               'admprof' ~ 'Admin professionalism',
                               'admfair' ~ 'Admin fairness',
                               'admoverall' ~ 'Overall admin quality',
                               "twork" ~ "Teamwork",
                               "learn" ~ "Learning Environment",
                               "balance" ~ "Team Balance",
                               "skill" ~ "Gameplay level",
                               "slserver" ~ "Environment for SLs",
                               "toxic" ~ "Overall server environment",
                               'maprot' ~ "Satisfaction with map rotation",
                               'moderot' ~ "Satisfaction with mode rotation",
                               "basecamp" ~ "Base camping enforcement",
                               "comms" ~ "Comms enforcement",
                               "asset" ~ "Asset claim system"),
  digits = everything() ~ 2) %>% add_difference() %>%  add_q(method = "bonferroni") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% 
  modify_header(stat_1 = "External clan member (N={n})", stat_2="Unaffiliated (N={n})", label = "Continuous (0-10) Variables") %>%  as_hux_table()

  
#Format
table21a <- diff_table_fmt(diff_table) %>%  set_caption("Table 21A - External clans vs unaffiliated players (Continious variables)")

#Display
table21a
Table 21A - External clans vs unaffiliated players (Continious variables)

Continuous (0-10) Variables

External clan member (N=111)

Unaffiliated (N=217)

Difference

p-value

q-value

Base camping enforcement7.458.41-0.97<0.0010.013
Teamwork7.808.29-0.490.0190.3
Team Balance6.846.290.550.0310.5
Satisfaction with map rotation6.447.31-0.870.0050.076
Environment for SLs7.287.80-0.510.0220.3
Comms enforcement8.028.53-0.520.0270.4
Asset claim system8.188.86-0.680.0190.3
Admin response time8.308.48-0.180.4>0.9
Admin professionalism8.028.36-0.340.11>0.9
Admin fairness8.188.44-0.260.2>0.9
Overall admin quality8.498.69-0.200.3>0.9
Learning Environment6.106.59-0.490.085>0.9
Gameplay level8.378.56-0.190.3>0.9
Overall server environment7.287.65-0.370.13>0.9
Satisfaction with mode rotation7.037.43-0.400.12>0.9
* P-value calculated using Welch's T-test. ** Q-value calculated using Bonferroni's correction

Key results from table 21A:

Check how to read the table above here

Strongly significant statistical differences in base camping enforcement.

Weakly significant statistical differences in:

  • Teamwork
  • Team Balance
  • Satisfaction with map rotation
  • Environment for SLs
  • Comms enforcement
  • Asset claim system

The only area where external clan members rate the server slightly better is in team balance.

We can see that there are differences in opinion between external clan members and unaffiliated players. It is important to note that these differences are not large in magnitude, but they are present and non-zero. The largest differences are in base camping enforcement. Anecdotally, some of the comments suggest the lower ratings are driven by the fact some people disagree with base camping rules in general.

Certain people do not agree with base camping rules, or they want “squad name based” asset claim rather than first come first serve. Both of those things are unlikely because there is general satisfaction with both rules. Even external clan members, who are marginally less satisfied than unaffiliated players, rate us quite highly in both areas - approximately 7/10 for base camping enforcement and 8/10 for the asset claim system. The other differences between the two groups are even smaller.

Table 21B below looks at the differences between external clan members on dichotomous questions.

#Remove previous
rm(diff_table, table21a)

#Build
dich_table <- df %>%  select('over1k', 'over1tt', 'sl', 'more_fraas', 'more_aas', 'more_inv', 'more_raas','more_tc', 'clan') %>% tbl_summary(by= 'clan',
  type = everything() ~ "dichotomous", statistic = all_dichotomous() ~ "{p}%",
  missing = "no", label = list('over1tt' ~ 'Over 1 year at TT?',
                               'over1k' ~ '1,000+ Hours in Squad',
                               'sl' ~ 'Do you SL open squads often?',
                               'more_raas' ~ 'Want more RAAS',
                               'more_aas' ~ 'Want more AAS',
                               'more_fraas' ~ 'Want more fogless RAAS',
                               'more_tc' ~ 'Want more Territory Control',
                               'more_inv' ~ 'Want more Invasion'),
  digits = everything() ~ 0) %>% add_p(test = everything() ~ "chisq.test") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_header(stat_1 = "External Clan Member (N={n})", stat_2="Unaffiliated (N={n})", label = "Dichotomous (Y/N) Variables") %>% as_hux_table()


#Format
table21b <- dich_table_fmt(dich_table) %>% set_caption("Table 21B - External clans vs unaffiliated players (Dichotomous variables)") %>% insert_row("Mode Preferences", "", "", "", after = 4) %>% merge_cells(5, everywhere) %>% merge_cells(5, everywhere) %>% set_align(5, everywhere, "left") %>% set_bold(5, everywhere)

bottom_border(table21b)[5, ] <- brdr(1, "solid", "black")
top_border(table21b)[5, ] <- brdr(1, "solid", "black")

#Display
table21b
Table 21B - External clans vs unaffiliated players (Dichotomous variables)

Dichotomous (Y/N) Variables

External Clan Member (N=111)

Unaffiliated (N=217)

p-value

1,000+ Hours in Squad80%48%<0.001
Over 1 year at TT?71%67%0.6
Do you SL open squads often?41%34%0.3
Mode Preferences
Want more fogless RAAS52%24%<0.001
Want more AAS37%12%<0.001
Want more Invasion30%54%<0.001
Want more RAAS30%41%0.072
Want more Territory Control41%33%0.2
* P-value calculated using Pearson's Chi-squared test

Key results from table 21B:

Check how to read the table above here

  • External clan members are far more likely to have 1,000+ hours in Squad.

  • External clan members want more AAS when compared to unaffiliated players, but the figure is only 37%.

  • A slight majority of external clan members want more fogless RAAS while a slight majority of unaffiliated players want more invasion.

External clan members do have some different preferences regarding game modes when compared to unaffiliated players. Their preferences line up quite well with the preferences of experienced players in general

See Table 20b.

, but external clan members show a slightly greater preference for fogless RAAS when compared to experienced players in general. The similarity in preferences is unsurprising since there is a lot of overlap between these two groups. The majority of external clan members are players with over 1,000 hours in Squad.

Comparison to Survey 3

We did a survey last year that asked many of the same questions. The tables below examine whether our scores have changed from last year.

#Remove previous
rm(dich_table, table21b)

#Load Survey 3 data
load("Data/s3.RData")

# Create dummy variable
s3 <- s3 %>% mutate(survey_year = 2022)
df <- df %>% mutate(survey_year = 2023)

#Merge
merged_df <- bind_rows(df, s3)

# Create ordered dummy in combined dataframe
merged_df$survey_year <- ordered(merged_df$survey_year, levels = c(2023, 2022))

#Build
diff_table <- merged_df %>% select(basecamp, admfair, admoverall, comms, admresp, admprof, twork, learn, balance, skill, toxic, maprot, moderot, slserver, asset , survey_year) %>% 
  tbl_summary(by= 'survey_year',
  type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean}",
  missing = "no", label = list('admresp' ~ 'Admin response time',
                               'admprof' ~ 'Admin professionalism',
                               'admfair' ~ 'Admin fairness',
                               'admoverall' ~ 'Overall admin quality',
                               "twork" ~ "Teamwork",
                               "learn" ~ "Learning Environment",
                               "balance" ~ "Team Balance",
                               "skill" ~ "Gameplay level",
                               "slserver" ~ "Environment for SLs",
                               "toxic" ~ "Overall server environment",
                               'maprot' ~ "Satisfaction with map rotation",
                               "basecamp" ~ "Base camping enforcement",
                               "comms" ~ "Comms enforcement",
                               "asset" ~ "Asset claim system"),
  digits = everything() ~ 2) %>% add_difference() %>%  add_q(method = "bonferroni") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_column_hide(ci) %>% modify_footnote(everything() ~ NA) %>% 
  modify_header(stat_1 = "Survey 4 (2023) (N={n})", stat_2="Survey 3 (2022) (N={n})", label = "Continuous (0-10) Variables") %>%  as_hux_table()

  
#Format
table22a <- diff_table_fmt(diff_table) %>%  set_caption("Table 22A - Survey 4 (2023) vs Survey 3 (2022) (Continious variables)")

#Display
table22a
Table 22A - Survey 4 (2023) vs Survey 3 (2022) (Continious variables)

Continuous (0-10) Variables

Survey 4 (2023) (N=330)

Survey 3 (2022) (N=313)

Difference

p-value

q-value

Base camping enforcement8.087.370.71<0.0010.009
Admin fairness8.358.030.320.0340.5
Overall admin quality8.638.310.310.0280.4
Comms enforcement8.367.950.400.0150.2
Admin response time8.428.51-0.090.5>0.9
Admin professionalism8.257.990.250.094>0.9
Teamwork8.138.110.02>0.9>0.9
Learning Environment6.436.390.040.8>0.9
Team Balance6.476.74-0.270.13>0.9
Gameplay level8.508.420.070.6>0.9
Overall server environment7.537.470.060.7>0.9
Satisfaction with map rotation7.026.840.180.4>0.9
moderot7.29NA
Environment for SLs7.627.430.190.2>0.9
Asset claim system8.638.260.370.0510.7
* P-value calculated using Welch's T-test. ** Q-value calculated using Bonferroni's correction

Key results from table 22A:

Check how to read the table above here

We received slightly better scores across the board in Survey 4 versus Survey 3.

Strongly significant statistical differences in base camping enforcement

Weakly significant statistical differences in:

  • Admin fairness
  • Overall admin quality
  • Comms enforcement

The encouraging thing about the table above is that we haven’t become worse! We performed marginally better in some categories and about the same in most others. We consider this a decent outcome! Sadly, this also means that the same areas of concern that were present last year (the learning environment and team balance) continue to be areas of concern this year.

This was addressed earlier.

#Remove previous
rm(diff_table, table22a, s3)

#Build
dich_table <- merged_df %>%  select('over1tt', 'over1k', 'regular', 'clan', 'sl', 'survey_year') %>% tbl_summary(by= 'survey_year',
  type = everything() ~ "dichotomous", statistic = all_dichotomous() ~ "{p}%",
  missing = "no", label = list('over1tt' ~ 'Over 1 year at TT?',
                               'over1k' ~ '1,000+ Hours in Squad',
                               'sl' ~ 'Do you SL open squads often?',
                               'regular' ~ 'Do you consider TT your primary server?',
                               'clan' ~ 'External clan member?'),
  digits = everything() ~ 0) %>% add_p(test = everything() ~ "chisq.test") %>% 
  modify_footnote(everything() ~ NA) %>%
  modify_header(stat_1 = "Survey 4 (2023) (N={n})", stat_2="Survey 3 (2022) (N={n})", label = "Dichotomous (Y/N) Variables") %>% as_hux_table()


#Format
table22b <- dich_table_fmt(dich_table) %>% set_caption("Table 22B - Survey 4 (2023) vs Survey 3 (2022) (Dichotomous variables)")

#Display
table22b
Table 22B - Survey 4 (2023) vs Survey 3 (2022) (Dichotomous variables)

Dichotomous (Y/N) Variables

Survey 4 (2023) (N=330)

Survey 3 (2022) (N=313)

p-value

Over 1 year at TT?68%46%<0.001
1,000+ Hours in Squad59%53%0.12
Do you consider TT your primary server?89%90%0.7
External clan member?34%34%>0.9
Do you SL open squads often?36%35%0.8
* P-value calculated using Pearson's Chi-squared test

Key results from table 22B:

Check how to read the table above here

  • A much greater percentage of the survey respondents have been at TT for more than a year when compared to last year.

It is nice to see that a much greater proportion of respondents are long term players at TT. However, this does raise one potential concern: if more than 70% of respondents have been at TT for more than a year when compared to only 46% last year, does this mean that TT has stopped attracting new players? We know that Squad has been through major updates in the past year and there have been thousands of new players (and probably thousands of old ones who have simply left the game) and it seems that we at TT have not done a good enough job in marketing ourselves to newer players. This is also reflected in Table 22A where we received only average scores for the category of “learning environment” across both years.

Clusters

Let us check whether we can identify some clusters and groups in the survey scores:

#Remove previous
rm(dich_table, merged_df, table20b)

#Create a new analysis frame

df2 <- df

#Row means which handle missing values sensibly
df2$admscore <- rowMeans(df2[, 8:11], na.rm=TRUE)
df2$envscore <- rowMeans(df2[, c(12:17, 41)], na.rm=TRUE)

#Modify new analysis frame - prepare for kmeans clustering
df2 <- df2 %>% select('admscore', 'envscore') %>% filter(!is.na(admscore))

#Seed set to random number, 2, for reproducibility
set.seed(4)
#Cluster
clust <- kmeans(df2, centers = 4)
#Map cluster ID to OBS
df2$cluster <- as.character(clust$cluster)

#df3 %>% ggplot(aes(x=admscore, y=envscore, colour = cluster)) + geom_jitter(width = 0.1, height = 0.1) + theme_bw()

########OPTIMAL CLUSTERS############
# k.max <- 10
# data <- df3
# wss <- sapply(1:k.max, 
#               function(k){kmeans(data, k, nstart=50,iter.max = 15 )$tot.withinss})
# wss
# plot(1:k.max, wss,
#      type="b", pch = 19, frame = FALSE, 
#      xlab="Number of clusters K",
#      ylab="Total within-clusters sum of squares")

### THE ANSWER IS 4 - YOU CAN SEE IT IF YOU RUN THIS
########OPTIMAL CLUSTERS############

#Assign cluster ID sensble names
df2 <- df2 %>% mutate(Group= case_when(cluster==4 ~ "Adm high, Env avg",
                                       cluster==1 ~ "Env avg, Adm avg",
                                       cluster==3 ~ "Both High",
                                       cluster==2 ~ "They hate us"))

df2$Group<- ordered(df2$Group, levels = c("Both High", "Adm high, Env avg", "Env avg, Adm avg", "They hate us"))

#Build chart
clust_chart <- ggplot() + 
  geom_jitter(data = df2, aes(x=admscore, y=envscore, colour = Group), width = 0.1, height = 0.1) +   
  geom_point(mapping = aes_string(x = clust$centers[, "admscore"], y = clust$centers[, "envscore"]),color = "magenta", size = 4) + 
  theme_bw() + 
  scale_color_manual(values = c("black", "blue", "purple", "red" )) + 
  theme(panel.border = element_blank(), panel.grid.major = element_blank(), panel.grid.minor = element_blank(), axis.line = element_line(colour = "black")) + 
  xlab('Average score for admin categories') + ylab('Average score for environment categories') + 
  labs(title = "Chart 16: Why play at TT?") + theme(legend.position = "bottom") + theme(plot.title = element_text(hjust = 0.5))

#Display
clust_chart

The average scores of participants for administration categories is shown along the X-axis and the average score for the server environment categories is shown on the Y-axis. I assume that people play at TT because they either like the way it is run (the administration) or because they like the environment. I do not think people play at TT because of our asset claim system or because they like how we enforce base camping. So, these questions about rules were not considered when producing this chart.

Participants are assigned to groups using a clustering algorithm. Chart 16 above shows roughly four groups:

table23 <- df2 %>% select(admscore, envscore, Group) %>% 
  group_by(Group) %>%  
  summarise(N = n(), "Group Mean (Admin)" = round(mean(admscore), 2), " Group Mean (Environment)" = round(mean(envscore), 2))

table23  <- as_hux(table23) %>% theme_article() %>% set_caption("Table 23 - Cluster Statistics")

table23
Table 23 - Cluster Statistics
GroupNGroup Mean (Admin) Group Mean (Environment)
Both High1159.258.66
Adm high, Env avg979.197.13
Env avg, Adm avg897.3 6.6 
They hate us204.753.98

Key results from table 23:

  • The largest group is composed of people who rate TT highly across both administrative and environmental questions.

  • Two roughly equal groups - one rates us very high in administration but slightly above average in environmental questions, while the other rates us slightly above average across both administrative and environmental questions.

  • The smallest group is a tiny minority who seem to not like us very much.

Responses to comments

Thank you for reading the survey report!

Affinity has responded to a selection of comments below for your perusal and enjoyment!

Comment 1

Comment 1

Dear Affinity,

You have way too many admins. Way too many players who have admin abilities but are not admin material. Literal children/man babies. Most of your admins suck and are clearly biased towards a certain type of player and play style. Openly hostile and shitty towards others who don’t share the same mindset. They gang up on and bully players who don’t conform. Needlessly toxic. Hopefully the ICO removes most of them. They are unbearable. Oh, and your map rotation is hot garbage. Honestly one of the worst in the game. Whoever puts Fool’s Road, Mestia, or Logar in the rotation needs to be taken out back and put out of their misery because clearly they are brain damaged beyond repair and incapable of living a functional and fulfilling life.

Sincerely,

Brain Damaged Baby Badmins

Response 1

Dear Brain Damaged Baby Badmins,

At Tactical Triggernometry, we treat the risk of traumatic brain injuries seriously. Once an admin tests a 7.0 or above on the Blueberry Concussive Scale they are put out to pasture. In an act of mercy, we process and repurpose these retired admins into glue. We distribute the glue among active admins in an effort to treat Logar Brain Rot. The glue’s fumes provide utility as an effective pain medication. Recycle, reuse, renew.

It is not fun to feel ganged up on, however teamwork does require some type of conformity. Admins do not enforce a specific meta, yet a team may make collective decisions you don’t completely agree with. Players must demonstrate a good faith attempt to work with and, more importantly, for their team. Share your perspective, listen to others, but keep an open mind to compromise with sound ideas.

There is a level of trust that is earned once people know you are coming from a positive, pro-teamwork mindset, rather than someone who just doesn’t like being told what to do. The former are rare, but if you are one of these fine fellows, we’d hate to lose you. If you feel something is over the line – from an admin or a player – bring those instances to the #contact-admins channel, or message an admin.

I am sorry you dislike some maps. If you stop liking what you like and start liking what other people like you can let the light back into your life.

Sincerely,

Affinity

Comment 2

Comment 2

Dear Affinity,

Admins need to be more active past 10pm. Many times my reports go unattended until the next day.

Sincerely,

Coveting Coverage

Response 2

Dear Coveting Coverage,

We have a fair number of scumbag admins that play into the wee hours of the morning. This is reflected in the number of admin actions the server accrues during late night hours. Off hour admins are always in high demand. If you are a good chap ready to volunteer, then feel free to reach out.

The late-night gremlin is a strange, unpredictable creature worthy of study. We measure our expectations for the late-night gremlin’s ability to create engaging game play, nor are we surprised when they act unexpectedly. Gremlins are known to do a Bad Dumb, hit a bong, then melt away into the night never to be seen again.

Maintain your distance from the gremlin and never stare a gremlin directly in the eyes. Stay safe out there.

Sincerely,

Affinity

Comment 3

Comment 3

Dear Affinity,

I’ve seen some “micromanagement” (for lack of a better word) of HABs that seems restrictive and overbearing i.e. literally digging down friendly HABs to enforce some kind of “1-HAB on the map at a time” rule. It’s not consistent and I wasn’t an SL any of the times I’ve seen it happen but it’s a little frustrating nonetheless.

Sincerely,

HAB Spam Dan

Response 3

Dear HAB Spam Dan,

That is despicable behavior. Like I’ve always said, the more HABs, the merrier. You should get in that truck and build some more. Trust me, you wouldn’t want to waste any time digging that unprotected radio. No, don’t worry about this C4 in my hand or that enemy helo circling above. Let’s move, SL. Hooah!

More seriously, we do not have a server rule on the number of HABs in play. We do have many players who are aware that unprotected and unnecessary radios are a common way to lose a game. An unprotected radio is a free 20 tickets for the enemy team and an unnecessary HAB puts players in irrelevant places. In the rare case where admin actions occur, it is usually because a SL’s reasoning does not go beyond “I placed this HAB, it is mine, and I like it” without regard to the team’s needs.

Since it can only take 10 minutes for a HAB to go from desperately needed to total liability, this aspect of the game requires constant attention– which might explain why your SL felt micromanaged. Consider removing a radio if your team is low on tickets, when objectives shift, or when a HAB becomes detrimental to the players that spawn on it. If a majority of command net requests your SL dig down a radio, then it is likely best to remove it.

Sincerely,

Affinity

Comment 4

Comment 4

Dear Affinity,

Make it more clear who is a mod/admin while in the server.

Sincerely,

Badge Number Please

Response 4

Dear Badge Number Please,

Messages that appear on the left hand of your screen or in big yellow text are from an admin. VOIP that starts with “I am an admin and…” or “this is a rule, you need to…” is probably from an admin. You can confirm that a request is from an admin and verify a rule enforcement is legitimate with an !admin ping. Alternatively, you may pull over into the next well lit gas station you see.

Sincerely,

Affinity

Comment 5

Comment 5

Dear Affinity [edited for grammar]

Advertise your team balance so other servers are forced to balance better. I can’t play any server other than TT because they are almost always clan stack stomps 24/7. They are destroying the squad player base by grinding newbies in clan stacked meat grinders and it makes me sad and angry.

Sincerely,

Happy Balance Enjoyer

Response 5

Dear Happy Balance Enjoyer,

Thanks for the kind words. There are other servers that address team balance, though not as many as there could be. Two evenly matched teams going toe to toe is not the natural state of any online multiplayer FPS. Teams change game to game, players change day to day, and expectations of what a balanced game even looks like can be wildly different. Our experience has taught us that an effective balance effort is a challenge that requires a fair amount of attention.

I believe most of our regulars would agree that pub stomps are boring. For communities that do not actively balance, some do not have the resources, patience, or need for it. It would not be possible to address skill disparities without our core of regulars and volunteers. These groups are vital in creating competitive games and we cherish them dearly. Not every community will have enough loyal, willing contributors to spread talent between two teams. Other communities may not see a need for an active balance policy, or they may aim for a different vibe altogether.

I will happily tell other communities that our efforts, imperfect as they may be, do create more competitive games. Beyond that, enjoy the good games when they arrive.

Sincerely,

Affinity

Comment 6

Comment 6

Dear Affinity,

Environment can be a bit snarky or elitist sometimes. When people comment about balance in all chat, they are sometimes met with snarky responses such as “git gud” or “skill issue.” Those responses are not the most conducive to having a good team balance on the server, and can discourage people from trying to bring up balance problems.

Sincerely,

Snarky Balance Destroyer

Response 6

Dear Snarky Balance Destroyer,

I agree. Salty admins that disagree with a balance call should aim to educate a player with substance rather than engage in self-gratifying snark. I maintain that in the silliest cases, such as calls for balance from players on winning teams, some light ribbing is okay. We can work on this.

We have a guideline that mandates balance actions: two consecutive losses for a team with a 200+ ticket difference. We try to follow this policy closely, as shown in the 2022 Maps & Balance Report. This policy with objective metrics helps account for the limitations and uncertainties in team balance.

The core of the balance problem strikes at the heart of Squad. Effective team balance does not happen because an admin switched some names over. Effective balance most commonly requires an effort from individual players to lift up a team. The best (and, frequently, only) way to contribute to balance is for individuals to create a squad and help coordinate a team. If you make a balance call, try to be as specific when you describe what you think your team needs, then consider clicking that big green button.

Sincerely,

Affinity

Comment 7

Comment 7

Dear Affinity, [formatted paragraphs]

I think publicly creating “learning” events based around armor, invasion, TC etc. Generally just less commonly played game modes/layers to provide the player base more exposure to the parts of Squad maybe they don’t get to do often. For example, I never run armor but have been wanting to learn. I have two options currently, go to a different server and face little exposure to what awaits me on TT; or play TT and get dookied on and sit at main waiting for a vic reset while getting chitted on in AllChat.

Anything that the server can do to get the player base learning in ways outside of their comfort zone will make a more competent and dynamic player base. Although, without warning I understand how things like Tanks Talil may make the player base tank. Overall though, I love the server and still think it’s the best one out there. I just don’t feel comfortable squad leading or running armor, because if I do on TT it’s gg. Even though all I want to do is squad lead on TT.

Sincerely,

Performance Anxiety

Response 7

Dear Performance anxiety,

Come into our discussion channels to ask any questions you may have about a role. Upload footage to our media channel and ask for constructive criticism. Ask for an invite to an experienced armor squad as a LAT or third crewman. If you feel as though you are wasting an asset, then put it down for the match to do something else.

Instead of a full 9-man, newer infantry SLs can more easily learn to manage a squad size of 5-6 players. Create basic plans that will reliably contribute to the team: always place a rally, pick up the back caps, offer to play defense, and be the first to act on a nearby enemy radio or proxied HAB. Remember, as an infantry SL, your primary job is to keep your squad spawning together in relevant areas of the map.

There is no replacement for experience. Solicit and process criticism, but shrug off loser-itis screeching. Experienced players should understand the learning process, even if they are unhappy with a loss in the moment.

Sincerely,

Affinity

Comment 8

Comment 8

Truncated - removed some rambling and bad grammar

Dear Affinity,

The admin staff may have a hierarchy labelled in discord however there is absolutely no translation to that in the server, personally I hear constantly about how wild west it is, whether an admin whose been in the current game has decided something and then another random admin… I don’t expect or demand an admin to just sit there and admin the server for hours on end and not do anything else.

Simply put admin’s need to admin when the need to admin arises and sometimes the delay that occurs between when that’s necessary and when they fulfill that obligation can lead to a situation where they’re fixing something that has already ruined the gameplay experience… Map votes should also be occurring whenever there is 1 admin actively playing and not just the admins deciding what they want to play, if you cannot do this then you should in GOOD CONSCIENCE remove the MAP VOTE tag in your server description.

Sincerely,

Big Iron On His Hip

Response 8

Dear Big Iron On His Hip,

I do not believe we have to run a map vote every match that has an admin present to maintain a good conscience. We sleep soundly at night, good sir. We encourage and run many map votes, but we do allow some admin curation. I have not seen evidence there is “absolutely no translation” of a hierarchy to the server myself, so I would encourage you to share your intel. The “Wild West” description is inaccurate, anyway. The time-honored tradition of gentleman duels is a more apt comparison. In the handful of miscommunications admins may choose a weapon, a second, and a wig.

Regarding inaction, a report might be investigated then deemed inactionable with what we can glean. In an effort to be fair we try to minimize bad removals. We may err on the side of caution when we have limited information, no evidence, lack witnesses, and of course we may miss things as well. I am sorry you were frustrated by what appeared to you as a lack of action. Feel free to put a report in Discord or request a follow up to an in-game report. We can look and see what, if any, actions were taken.

Game breaking issues and Code of Conduct violations should be reported as clearly as you can, repeatedly if necessary. Admins do the bulk of the enforcement, but policing the server, including keeping admins honest, extends to a community effort. We appreciate help from players like you to provide corroborating evidence, identify violations, and create descriptive reports.

Sincerely,

Affinity

Acknowledgements

Many people assisted in the writing of this report.

I would like to thank the following people: Eyeofthehawks, PSG, Myeggo, and all the people who work on the technical side of TT for helping me assemble the data for this report and being patient with my inquiries throughout the process.

KTF, UNN, and SOF also deserve a mention here for being so generous with their server data and allowing me to use it.

I would also like to thank all the admins at TT who helped me design the survey and helped proofread this report with special thanks to Affinity, Zero, Kirb, and the entire TT Command team. Thank you for sticking with me throughout.

Any remaining errors in this report are my responsibility alone.

Thank you for reading.

Appendix

Frankly, if you are crazy enough to have read the report this far, chances are that you might just be crazy enough to go a little further. I must warn people that this section is meant for enthusiasts. However, a great deal of effort has gone into making it tractable for a lay audience. I apologise to you, dear reader, in advance for subjecting you to this.

The aim of this section is to try and estimate the impact of patches on Squad’s average weekly player count with a particular focus on the ICO. Obviously, there are lots of potential confounding factors to consider including, but not limited to:

  • Concurrent sales
  • Free weekends
  • Serial or Auto correlation (a given week’s average player count is driven by the previous week’s average player count and so forth)
  • Seasonal impacts (how to account for seasonal changes in player counts?)

All of these confounding factors make it hard to simply eyeball a chart of player counts to try and glean a sense of the impact of a particular patch. This is why we have to resort to slightly more advanced methods. I have used a basic, “off the shelf,” ARIMA model to try and get at the question of how patches affect player counts in Squad. Readers who want to skip the technicalities may go straight to the results.

The data

The data is publicly available and was obtained from SteamDB. It can be downloaded by using this link. Before getting into any technicalities, it is useful to simply plot the data that we are working with:

#Load packages
rm(list=ls()) # Clear all objects in the global environment
library(tidyverse) # For data manipulation + graphing
library(knitr) # To compile HTML document
library(readxl) # Reading excel
library(huxtable) # Tables
library(lubridate) # To work with dates and times
library(tseries) # Time series tests
library(forecast) # ARIMA
library(lmtest) # Tests
library(dotwhisker) #Dot whisker plots

#Load data

#Daily player count
daily <- read_excel("Data/sq_player_data.xlsx", col_types = c("date", "numeric", "text", "text"))

#Weekly price
weekly <-  read_excel("Data/sq_player_data.xlsx", sheet = "weekprice", 
                      col_types = c("date", "numeric", "numeric", "text"))


#Generate year week combination
daily <- daily %>% 
  mutate(year = year(date)) %>% 
  mutate(week = week(date)) %>% 
  unite("yw", year:week, sep = " ", remove=FALSE)

weekly <- weekly %>% 
  mutate(year = year(date)) %>% 
  mutate(week = week(date)) %>% 
  unite("yw", year:week, sep = " ", remove=FALSE)


#Merge, manage and rename
full <- full_join(daily, weekly, 'yw') %>%  
  dplyr::select(date.x, date.y, year.x, week.x, yw, players, fweekend, update, price, retail, sale)

colnames(full)<- c("date", "date.week", "year", "week", "yw", "players", "fweekend.c", 
                    "update", "price", "retail", "discount")


# Generate dummies

#Filter nonsense + post release
full <- full %>% 
  filter(date>'2015-12-15') %>% 
  mutate(post_release = ifelse(date>='2020-09-23', 1, 0))

#Patches and major/minor releases
full <- full %>% 
  mutate(patch = ifelse(is.na(update), 0, 1)) %>% 
  mutate(major_patch = case_when(update=="a16" ~ 1,
                                 update=="b17.0" ~ 1,
                                 update=="b18.0" ~ 1,
                                 update=="b19.0" ~ 1,
                                 update=="b20" ~ 1,
                                 update=="b21" ~ 1,
                                 update=="v1.0" ~ 1,
                                 update=="v2.0" ~ 1,
                                 update=="v2.12" ~ 1,
                                 update=="v2.15" ~ 1,
                                 update=="v3.0" ~ 1,
                                 update=="v4.0" ~ 1,
                                 update=="v5.0" ~ 1,
                                 .default = 0)) %>% 
  mutate(minor_patch = ifelse(major_patch==0 & patch==1 & update!="v6.0", 1, 0))

#Treat 6.0 separately
full <- full %>% mutate(ico = case_when(update=="v6.0" ~ 1, .default=0))


#Sales & free weekends
full <- full %>% 
  mutate(sale = ifelse(is.na(discount), 0 , 1)) %>% 
  mutate(fweekend = ifelse(is.na(fweekend.c), 0, 1))

#Remove duplicates
full <- full %>% 
  mutate(dup = case_when(date==lag(date) & players==lag(players) & sale==0 ~ "dup", .default = "No")) %>% 
  mutate(dup2= case_when(date==lag(date) & players==lag(players) ~ "dup", .default = "No")) %>% 
  filter(dup=="No") %>% filter(dup2=="No")


#Build dataset for weekly ARIMA

#Adjusted weekly count - folding year weeks" with 2 or fewer days into the next week (mainly end of year weeks)
weekly_arima <- full %>% 
  filter(date>'2015-12-16') %>% 
  mutate(date = as.Date(date)) %>%
  dplyr::select(date, yw, players, sale, fweekend, major_patch, minor_patch, post_release, ico) %>% drop_na() %>% 
  group_by(yw) %>% 
  mutate(weekcount = n()) %>% ungroup() %>% 
  mutate(adj_yw = ifelse(weekcount<=2, lead(yw), yw))


#Collapse
weekly_arima <- weekly_arima %>% 
  dplyr::select(-yw) %>% group_by(adj_yw) %>% 
  summarise(w_fweekend = max(fweekend), w_sale = max(sale), 
            w_major = max(major_patch), w_minor = max(minor_patch), ico = max(ico), eow = max(date),
            post_release = max(post_release), w_players = mean(players)) %>% arrange(eow)

# Stationarity tests
adf <- adf.test(weekly_arima$w_players)
kpss <- kpss.test(weekly_arima$w_players, null = "Trend")

##ADF INDICATES STATIONARY
##KPSS INDICATES NON STATIONARY

##KPSS indicates non-stationarity and ADF indicates stationarity - The series is difference stationary. Differencing is to be used to make series stationary. The differenced series is checked for stationarity and it was indeed found to be stationary.

#ACF AND PACF plots

# #PACF
# pacf.plot<- weekly_arima %>% mutate(d_pl = w_players - lag(w_players)) %>% 
#   select(d_pl) %>% drop_na()   
# pacf(pacf.plot$d_pl, lag.max = 12)
# 
# #ACF
# acf.plot<- weekly_arima %>% mutate(d_pl = w_players - lag(w_players)) %>% 
#   select(d_pl) %>% drop_na()   
# acf(acf.plot$d_pl, lag.max = 12)

#RESULTS ARE SAME AS THE Hyndman & Khandakar algorithm.

#Plot

chartac1<- weekly_arima %>% 
  ggplot(aes(x=eow, y=w_players)) + geom_line(colour = "blue") + 
  labs (x = ' ', y = 'Average weekly players', title = 'Appendix C1: Average weekly players in Squad') +
  theme_classic ()  +  
  theme(plot.title = element_text(hjust = 0.5)) +
  #Release
  geom_vline(xintercept = as.numeric(ymd("2020-09-23")), color="red", size =0.3, linetype = "dotted") +
  geom_text(aes(x=lubridate::ymd("2020-09-24"), label="\n v1 Release", y=4000), colour="red", angle=90, size = 3.5) +
  #2.12 and 2.15
  geom_vline(xintercept = as.numeric(ymd("2022-02-09")), color="red", size =0.3, linetype = "dotted") +
  geom_vline(xintercept = as.numeric(ymd("2022-04-13")), color="red", size =0.3, linetype = "dotted") +
  geom_text(aes(x=lubridate::ymd("2022-04-14"), label="\n 2.12 & 2.15", y=4000), colour="red", angle=90, size = 3.5) +
  #PLA 4.0
  geom_vline(xintercept = as.numeric(ymd("2022-12-07")), color="red", size =0.3, linetype = "dotted") +
  geom_text(aes(x=lubridate::ymd("2022-12-08"), label="\n v4 PLA", y=4000), colour="red", angle=90, size = 3.5) +
  #ICO (week of)
  geom_vline(xintercept = as.numeric(ymd("2023-09-28")), color="red", size =0.3, linetype = "dotted") +
  geom_text(aes(x=lubridate::ymd("2023-09-29"), label="\n v6 ICO", y=4000), colour="red", angle=90, size = 3.5) 


chartac1

The chart above shows the evolution of Squad’s average player count through the years. Some landmark releases are shown, but, for the sake of brevity, not all major releases are. It may be tempting for an observer to attribute big spikes to releases alone, just by eye balling the chart. However, this would miss the effects of other confounding factors such as sales, free weekends, etc. To quantify and decompose these factors, we need a more formal model.

Building the model

WARNING

This section does get slightly more involved with the guts of the model. If you dislike statistics and equations (and you somehow still made it this far), you should turn away now.

Certain assumptions which underlie the model are also spelled out and explained. This is important so that the interested reader can decide for themselves if they find the model, and thus its results, credible.

At the end of the day, this exercise should be viewed as a “back of the envelope” type calculation. Results can be sensitive to assumptions and modelling choices, and thus, should be read with caution.

The model used here is a standard introductory time series econometrics model known as an ARIMA model. ARIMA models are often used to understand univariate time series data. To learn more about ARIMA models, please read this. Briefly, an ARIMA model treats a given time series as a function of its own lagged values (the “autoregressive” or AR part in ARIMA), and as a function of exogenous shocks/lagged values of exogenous shocks (the “moving average” or MA part of the ARIMA model). The “I” in “ARIMA” is the order of integration (explained below).

An ARIMA model is normally written as:

\[ ARIMA(P,D,Q)\ where\ P\ is\ the\ lag\ order \ of \ the \ AR \ term, \ I \ is \ the \ order \ of \ integration, \\and \ Q \ \ is\ the\ lag\ order \ of \ the \ MA \ term \]

The model used in the analysis is an \(ARIMA(2,1,0)\). This means that there are two autoregressive (AR) terms, 0 moving average terms, and that the average weekly player count is integrated of order one - \(I(1)\).

When picking the parameters of an ARIMA model, the first step is to make sure that the time series that you are modelling, in this case, the average weekly player count, is stationary. I performed standard statistical tests (KPSS and ADF) to test whether the average player count was stationary. The results of the tests can be found in the code above and they indicated that the average weekly player was an \(I(1)\) process. This means that the average weekly player count has to be “differenced” (i.e., we have to calculate the change in the average weekly player count) once in order to make it stationary.

The next step is to decide the lag order of the AR and MA terms. We can do this by looking at PACF and ACF plots of the weekly player count respectively, or, we can use an algorithm such as the Hyndman & Khandakar (2008) algorithm. Both methods suggested that the correct ARIMA model for this situation was \(ARIMA(2,1,0)\). This can be verified in the code above.

We are not actually interested in the two AR terms (the model has no MA terms) - they exist to merely soak up the variation that is caused by the autoregressive elements of the average weekly player count. In other words, the ARIMA terms are control variables and controlling for them allows us to look past them at variables we actually care about. The actual variables of interest are steam sales, free weekends, and game updates. Our main task is comparing the ICO’s effect on the average player count relative to other major updates after controlling for concurrent steam sales, etc.

We must make some modelling choices in how we design these variables. These choices, and the rationales behind them, are shown below:

Choices made during the construction of the dataset:

Weekly rather than daily data: The unit of analysis in the model is “the change in average weekly player counts.” Daily data was available, but condensing the data into a weekly format is more appropriate for the type of data we have. Daily data are too volatile, and effects of sales and patches can take multiple weeks to fully manifest.

Free Weekends: Free weekends are modelled as a dummy variable set to 1 if there is a free weekend day in that week. For example, a free weekend that goes from Thursday 12AM Pacific time to Monday 3AM Pacific time will mean that the week leading up to the Friday of the free weekend shall be marked as a “free weekend” week and the week that starts from Sunday will also be marked as a “free weekend” week. Therefore, the impact of that free weekend is spread over two weeks. Only free weekends since release (post version 1 .0) are included.

Sales: Sales are modelled as a dummy variable set to 1 if there is a sale at any point during that week or the preceding week. This means that every time a sale occurs, the model assumes the impact is felt over two weeks. This choice fit the data better. The results do not change much whether the week after the sale is counted as part of the sale or not.

Major Patches: Major patches are defined according to this wiki page. Two additions I made were patch 2.12 (the lighting overhaul) and version 5.0 (the addition of the PLANMC and VDV). The wiki has not been updated to include anything later than 4.1 at the time of writing.

Major patches are modelled as a dummy variable set to 1 if there is a major patch released at any point during that week or the preceding three weeks. Therefore, a window of 4 weeks (a month) is used to study the impact of a major patch. The results do not change much whether a three or four week window is used. The choice of 4 weeks just fit the data slightly better. Anything below three weeks is too short a period (when coupled with sales, etc.) to notice the impact of a major patch.

Minor Patches: Minor patches are patches that are not major patches. They are modelled as a dummy variable set to 1 if there is a patch released during that week.

ICO: The ICO, like everything else, is also modelled as a dummy variable. It is modelled identically, but separately, to a major patch.

Now that all the salient choices made during the model building process have been fleshed out, we can write down the full \(ARIMA(2,1,0)\) with the external explanatory variables mentioned above in a linear form:

\[ \begin{align*} \Delta Y_{t} = \beta _{0} + \lambda _{1}\Delta Y_{t-1} + \lambda _{2}\Delta Y_{t-2} + \beta_{1} ICO + \beta_{2} Maj.Patch + \beta_{3} Min.Patch + \beta_{4} Sale + \beta_{5} FW + \varepsilon\ \end{align*} \]

\(Y_{t}\) is the average weekly player count during week \(t\) and \(\Delta\) is a difference operator. Therefore, \(\Delta Y_{t}\), the variable the model seeks to explain, is the change in the average weekly player count during week \(t\). The right hand side of the equation consists of the following terms:

\[ \begin{bmatrix} \Delta Y_{t-1} \ \&\ \Delta Y_{t-2} \\ \lambda_{1} \ \&\ \lambda_{2}\\ ICO\\ Maj.Patch\\ Min.Patch\\ Sale\\ FW\\ \beta_{1}\ to\ \beta_{5} \end{bmatrix} = \begin{bmatrix} Autoregressive\ (AR)\ terms\\ Estimated\ coefficients\ on\ AR\ terms\\ ICO\ \ Release\\ Major\ Patch\ (Yes/No)\\ Minor\ Patch\ (Yes/No)\\ Sale\ (Yes/No)\\ Free\ weekend\ (Yes/No)\\ Estimated\ coefficients\ on\ variables\ of\ interest\\ \end{bmatrix} \]

Now that we have built the model, we can move on to the next stage, estimation.

Estimation & Results

The next lines of code prepare the data and perform the estimation. Before we get to the results, it is instructive to check how well the model fit the data:

#Remove previous
rm(adf, chartac1, kpss, adf)

#Model estimation

#Building the parameters

#2 weeks for major patches, sales and ico
weekly_arima <- weekly_arima %>% 
  mutate(w2_sale = ifelse(lag(w_sale)==1, 1, w_sale)) %>% 
  mutate(w2_major = ifelse(lag(w_major)==1, 1, w_major)) %>% 
  mutate(w2_ico = ifelse(lag(ico)==1, 1, ico))

#3/4 week major patches
weekly_arima <- weekly_arima %>% 
  mutate(w3_major = ifelse(lag(w2_major)==1, 1, w2_major)) %>% 
  mutate(w3_ico = ifelse(lag(w2_ico)==1, 1, w2_ico)) %>% 
  mutate(w4_major = ifelse(lag(w3_major)==1, 1, w3_major)) %>% 
  mutate(w4_ico = ifelse(lag(w3_ico)==1, 1, w3_ico)) 

#############OTHER MODELS TO TRY IF THE READER WANTS TO VERIFY SENSITIVITY TO ASSUMPTIONS#########################
# external_p1s1 <- weekly_arima %>% dplyr::select(w_sale, w_fweekend, w_major, w_minor, ico) %>% as.matrix(.)
# external_p2s2<- weekly_arima %>% dplyr::select(w2_sale, w_fweekend, w2_major, w_minor, w2_ico) %>% as.matrix(.)
# external_p2s1<- weekly_arima %>% dplyr::select(w_sale, w_fweekend, w2_major, w_minor, w2_ico) %>% as.matrix(.)
# external_p3s2 <- weekly_arima %>% dplyr::select(w2_sale, w_fweekend, w3_major, w_minor, w3_ico) %>% as.matrix(.)
# external_p3s1 <- weekly_arima %>% dplyr::select(w_sale, w_fweekend, w3_major, w_minor, w3_ico) %>% as.matrix(.)
# external_p4s1 <- weekly_arima %>% dplyr::select(w_sale, w_fweekend, w4_major, w_minor, w4_ico) %>% as.matrix(.)
# fit_p1s1 <- auto.arima(weekly_arima$w_players, xreg = external_p1s1)
# fit_p2s2 <- auto.arima(weekly_arima$w_players, xreg = external_p2s2)
# fit_p3s2 <- auto.arima(weekly_arima$w_players, xreg = external_p3s2)
# fit_p3s1 <- auto.arima(weekly_arima$w_players, xreg = external_p3s1)
# fit_p2s1 <- auto.arima(weekly_arima$w_players, xreg = external_p2s1)
# fit_p4s1 <- auto.arima(weekly_arima$w_players, xreg = external_p4s1)
# coeff_p1s1 <- lmtest::coeftest(fit_p1s1)
# coeff_p2s2 <- lmtest::coeftest(fit_p2s2)
# coeff_p3s2 <- lmtest::coeftest(fit_p3s2)
# coeff_p3s1 <- lmtest::coeftest(fit_p3s1)
# coeff_p2s1 <- lmtest::coeftest(fit_p2s1)
# coeff_p4s1 <- lmtest::coeftest(fit_p4s1)
####################################################################################################################

#Sets of external regressors to feed ARIMA
external_p4s2 <- weekly_arima %>% dplyr::select(w2_sale, w_fweekend, w4_major, w_minor, w4_ico) %>% as.matrix(.)

#Estimation
fit_p4s2 <- auto.arima(weekly_arima$w_players, xreg = external_p4s2)

#Coefficients
coeff_p4s2 <- lmtest::coeftest(fit_p4s2, save = TRUE)

#Build Fitted vs actual chart

#Extract predicted values
fitted <- as_tibble(fit_p4s2[["fitted"]])
chartdf <- weekly_arima %>%  select(adj_yw, eow, w_players)
chartdf <- cbind(chartdf, fitted)

#Build Chart
chartac2<- chartdf %>% drop_na() %>% 
  ggplot(aes(x=eow)) + geom_line(aes(y = w_players, colour = "Actual data"),size = 0.3, alpha = 0.5) + geom_line(aes(y = x, colour = "Model Prediction"), size = 0.3) +
  labs (x = ' ', y = 'Average weekly players', title = 'Appendix C2: Fitted values vs Actual') +
  theme_classic ()  +  
  theme(plot.title = element_text(hjust = 0.5)) + 
  scale_colour_manual(name = '', values = c("Actual data" = "darkred", "Model Prediction" = "steelblue")) + theme(legend.position = "bottom")

#Display
chartac2

The red line in the chart above is identical to the one in Appendix: Chart 1 while the blue line represents the model’s prediction. The model is a good fit for the data at hand. It turns out that we can explain virtually all of the variation in average weekly player counts with an off the shelf ARIMA model and some common sense explanatory variables. The model is also well behaved and passes the standard diagnostic tests.

The following table shows the results of the model and provides the estimates of all coefficients shown in the equation above:

#Remove previous
rm(chartac2, chartdf, external_p4s2, fitted)

#Ljung-Box test and residual graphs - for readers to verify
#checkresiduals(fit_p4s2, lag = 5)

# READERS CAN RUN THE LINE OF CODE ABOVE TO SEE THAT RESIDUALS ARE WELL BEHAVED. 
# MODEL PASSES LJUNG-BOX TEST.

#Build table
regtable <- huxreg(coeff_p4s2,  
                   number_format = 2, 
                   stars = c(`*` = 0.1, `**` = 0.05, `***` = 0.01),  note  = "Significance levels: {stars}.",
                   error_pos = "right",
                   coefs = c("λ1 - AR(1)" = "ar1", "λ2 - AR(2)" = "ar2", "β1 - ICO" = "w4_ico",
                             "β2 - Major Patch" = "w4_major", "β3 - Minor Patch" = "w_minor", 
                             "β4 - Sales" = "w2_sale","β5 - Free Weekend" = "w_fweekend"), 
                   statistics = c("N" = "nobs")) %>%
  set_width(1) %>% set_italic(final(1), 1) %>% insert_row("Variable", "Coefficients", "Standard errors", after = 1) %>% 
  set_align(everywhere, everywhere, 'center') %>% merge_cells(10, 2:3) %>%  .[-1, ] %>% set_bold(1, everywhere) %>% 
  set_caption("Appendix Table 1: ARIMA model results") %>% set_background_color(c(2,4,6,8), everywhere, "grey95") 

#Formatting for significance
regtable <- regtable %>%   
  set_background_color(c(4,5,7,8), 2, "#6aa84f") %>% 
  set_text_color(c(4,5,7,8), 2, "white") %>% 
  set_bold(c(4,5,7,8), 2)

#Borders
top_border(regtable)[1, ] <- brdr(1, "solid", "black")
bottom_border(regtable)[c(1,8,9), ] <- brdr(1, "solid", "black")

#Display
regtable
Appendix Table 1: ARIMA model results
VariableCoefficientsStandard errors
λ1 - AR(1)-0.17 ***(0.05)
λ2 - AR(2)-0.13 ***(0.05)
β1 - ICO1213.89 *(638.54)
β2 - Major Patch820.90 ***(192.36)
β3 - Minor Patch58.50(128.23)
β4 - Sales599.95 ***(108.44)
β5 - Free Weekend1951.30 ***(320.79)
N407
Significance levels: *** p < 0.01; ** p < 0.05; * p < 0.1.

Here is how to read Appendix Table 1 above:

  • Rows highlighted in green are both statistically significant (at varying confidence levels) and variables of interest. The confidence level is shown by the asterisk on the coefficients (see note on table).

  • The coefficients can be interpreted as “increases in average weekly player counts after controlling for the other variables.

  • These effects can (and probably do) decay over time. The environment is ever changing, and new sales/patches are constantly being rolled out which reinvigorate the process.

I understand that the coefficients above are abstract and confusing to many. I tend to find that graphs help in making sense of abstract ideas. The plot below shows the effects in a more tangible way:

#Build chart df
chartdf <- as_tibble(slice(regtable, 2:9))
chartdf$model1 <- str_remove_all(chartdf$model1, "[*]") %>% as.numeric(.)
chartdf$model1.error <- str_remove_all(chartdf$model1.error, "[()]") %>% as.numeric(.)
colnames(chartdf) <- c("term", "estimate", "std.error")

#Build plot
dotplot <- chartdf %>% filter(str_detect(term, "β")) %>% 
  dwplot(., ci = .8) + theme_classic() + 
  theme(legend.position = "none") + 
  labs (x = 'Change in average weekly players', y = ' ', title = 'Appendix C3: Dot & whisker plot') + 
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_vline(xintercept = 0 , linetype="dotted", color = "red", size=0.5) 

#Display
dotplot

Key results from Appendix Table 1 and Appendix Chart 3:

  • The dots in the middle of the red lines in the chart above are the coefficients from Appendix table 1. The lines extending in either direction of the dot are the confidence intervals. The wider the lines, the more uncertain the model is about the “true” value of the coefficient. Think of the lines as providing a “range of estimates” for the coefficient at hand.

  • After controlling for other variables, major patches increased the average weekly player count by approximately 821 players.

  • Similarly, the ICO was associated with an increase of approximately 1,214 in the average weekly player count. The model is far more uncertain about the ICO than it is about the other major patches as indicated by the wide error bars. What we can say is that even after controlling for sales, free weekends, and the nature of the time series process itself (through the \(AR\) terms in the model), the ICO had a net positive impact on the average weekly player count in line with other major patches.

  • Minor patches were not statistically significant after controlling for the other variables. Notice that the error bars for minor patches include “0” as a possibility in the chart above.

  • Sales were associated with an increase of roughly 600 in the average weekly player count after controlling for the other variables. Unsurprisingly, free weekends were associated with the largest increase in the average weekly player count - approximately 2,000.

After all that, where do we stand on our central question - namely, how does the ICO compare to other major patches? The short answer is that while we can say that the ICO performed in line with the average major patch, there were some major patches (like the 4.0 PLA update) that delivered greater increases in the average weekly player count. There are two reasons why the error bars around the ICO are so wide in Appendix Chart 3 when compared to the error bars around other major patches:

  • We do not have enough data post ICO. The future is still unwritten.

  • The other major patches are modelled as a “composite variable” meaning that the estimated coefficient is drawn from many data points. Therefore, the model has more data to work with and can estimate a more precise coefficient for the average of the other major patches.

An academic point of some interest is that during the model selection process, the algorithm did not suggest any \(MA\) or moving average terms. This indicates that the variation in the average weekly player count in Squad has not really affected, so far, by factors “outside” the game (such as other big releases) and appears to be a largely self-contained process.

This concludes the report. I do not know or quite understand why anyone would have read this far. However, to those that did, thank you. You are what makes the server what it is and for that, you have our gratitude.