Summary

The purpose of this report is to look at the maps that are played on the TT public server and examine issues around team balance. Team Balance was a big focus in the previous survey. This report aims to provide some empirical basis for discussions around map rotation and balance.

As before, this analysis was done using popular, free and open source statistical computing language called R. The code is presented alongside the results so that the results are transparent and reproducible. The results are summarised below:

Maps: In terms of map variety, when it counts (ie - during the hours from noon to midnight Eastern time), no map is played more than twice on average (see Chart 4A). The distribution of maps also closely resembles the results of Chart 7 in the previous survey which suggests that we largely play what people want us to play.

Modes & Layers: TT is, at its core, a RAAS/AAS server. These two modes make up more than 90% of what we play (see Charts 5A and B). Layers and maps come in all shapes and sizes. Some say that we play too many infantry focused layers while others chafe at every “big RAAS layer” that comes up. Obviously, we cannot please everyone and we try to find a middle ground.

To get at this question, first, I categorised each layer and then calculated the number of each type in squad. Charts 6A and B show these categories and how prevalent they are in Squad. Then, I calculated how prevalent each type is on the server. Charts 7A, B and C look at how often we play layers with helicopters while Charts 8A, B and C look at how often we play each layer type. Judging from the data, we do not over or under-play infantry layers compared to armour layers or layers with helicopters compared to layers without helicopters. The types of layers we play in Charts 7 and 8 correspond broadly to their prevalence in Squad (as seen in Charts 6A and B).

This is not to say that playing each layer type as often as it appears in Squad is the “optimal mix.” Some players may want us to overweight infantry layers or layers with helicopters. These are valid preferences. However, it does seem that when it comes to RAAS and AAS layers, we do strike a good balance by not over or under-weighting certain layer types.

Balance: On the whole, we do a decent job on balance if we hold ourselves our stated balance policy. We are in compliance the vast majority of time. Average ticket differentials are consistent across time of day and layer type. Even if we lowered our policy threshold from 200 tickets to 150 or simply went by consecutive wins, we still see that streaks are rare and are mostly broken by the third round.

The remainder of the report examines these topics in greater detail. The raw data used can be found here:

Load & Clean data

Before we delve in, we must load the relevant R packages to do the analysis:

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(readxl) # To read and import data
library(lubridate) # Deal with times and dates
library(kableExtra) # Tables

The next step is to load and clean the data. Examples of things that need to be cleaned include, but are not limited to:

  • Instances of Jensen’s Range/Pacific proving grounds
  • Instances of seed/skirmish layers that are used for seeding.
  • Instances of “maprolls” such as restarts and post crash maprolls

The aim is to produce a dataset of actual rounds with characteristics such as map, layer, game mode, winning margin, time of day the round was played, whether the round was played in “prime-time”, etc. The code below does this.

#Data from TT server
maps <- read_excel("Data/baldata.xlsx")

#### CLEAN SERVER DATA####

# Remove Jensen's, pacific, seed and skirmish
df <- maps %>% 
  filter(!grepl('Seed|Training|Skirmish', mode)) %>% 
  filter(!grepl('Pacific|Jensen', level))
  
# Clean up timezones
df$utc_time <- ymd_hms(df$datetime_utc, tz = "UTC")
df$est_time <- with_tz(df$utc_time, "America/New_York")

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

# Clean up maprolls
# Note:I checked invasions manually - they are not relevant in the sense that 
#none of them lasted 2 hours - team 2 tickets always full or near full
df <- df %>% filter(loser_tickets==0)

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

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

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

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

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

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

# Patches

df <- df %>% 
  mutate(harju = ifelse(day>= lubridate::as_date('2022-11-09'), 1, 0)) %>% 
  mutate(pla = ifelse(day>= lubridate::as_date('2022-12-07'), 1, 0))

# Duration and time between rounds (to calculate "end of server day")
df <- df %>% 
  mutate(dur_min = duration_seconds/60) %>% 
  mutate(time_diff = est_time - lag(est_time))
  

#Tick diff
df <- df %>% mutate(tick_diff = winner_tickets - loser_tickets)

# Ticket buckets
df <- df %>% mutate(tick_bucket = case_when(tick_diff<= 100 ~ "Below 100",
                                            tick_diff<= 150 & tick_diff>100 ~ "101 to 150",
                                            tick_diff <= 200 & tick_diff>150 ~ "151 to 200",
                                            tick_diff>200 ~ "Above 200"))


df$tick_bucket <- ordered(df$tick_bucket, levels = c("Below 100", 
                                          "101 to 150", 
                                          "151 to 200", 
                                          "Above 200"))
  
#### END OF CLEAN SERVER DATA ####

#### CLEAN LAYER DATA ####

#Layer data
sqlayers <- read_excel("Data/layers.xlsx", sheet = "layers")

#Clean blank entries 
sqlayers <- sqlayers %>%  filter(!is.na(layer_sheets))

#Clean layer names

#Fix spaces
sqlayers<- sqlayers %>% 
  mutate(layer = str_replace_all(layer_sheets, "_", " "))

#Remove 0 from layer numbers less than 10
sqlayers <- sqlayers %>% 
  mutate(last_number = str_sub(layer, start= -1))

sqlayers$last_number <- as.numeric(sqlayers$last_number)

sqlayers <- sqlayers %>% 
  mutate(layer = if_else(last_number!=0, str_replace_all(layer, "0",""), layer)) %>% 
  select(-last_number)

#Fix names
sqlayers <- sqlayers %>% 
  mutate(layer = str_replace(layer, "AlBasrah", "Al Basrah")) %>% 
  mutate(layer = str_replace(layer, "BlackCoast", "Black Coast")) %>%
  mutate(layer = str_replace(layer, "FoolsRoad", "Fool's Road")) %>% 
  mutate(layer = str_replace(layer, "GooseBay", "Goose Bay")) %>% 
  mutate(layer = str_replace(layer, "Kohat", "Kohat Toi")) %>% 
  mutate(layer = str_replace(layer, "Manic", "Manic-5")) %>% 
  mutate(layer = str_replace(layer, "Sumari", "Sumari Bala")) %>% 
  mutate(layer = str_replace(layer, "Tallil", "Tallil Outskirts")) %>% 
  mutate(layer = str_replace(layer, "Tallil Outskirts AAS v1", "Tallil Outskirts AAS_v1"))

#Clean up missing values

sqlayers <- sqlayers %>% 
  mutate(apc_one = replace_na(apc_one, 0)) %>% 
  mutate(apc_both = replace_na(apc_both, 0))

#Create proper tank/helo 1 layers (0 when both = 1)

sqlayers <- sqlayers %>% 
  mutate(tank_one = if_else(tank_both == 1, 0, tank_one)) %>% 
  mutate(helo_one = if_else(helo_both == 1, 0, helo_one))

#Heli layers
sqlayers <- sqlayers %>% 
  mutate(heli = case_when(helo_both == 1 ~ "Both teams have helicopters",
                   helo_one == 1 ~ "Only one team has a helicopter",
                   helo_one == 0 ~ "No helicopters on layer"))

sqlayers $heli <- ordered(sqlayers$heli, levels = c("Both teams have helicopters",
                                             "Only one team has a helicopter",
                                             "No helicopters on layer"))

# Full monty, medium and infantry focused layers

sqlayers <- sqlayers %>% 
  mutate(full_monty = if_else(tank_both == 1 & apc_both == 1 & helo_both==1, 1, 0)) %>% 
  mutate(heavy = if_else(tank_both==1 & full_monty==0, 1, 0)) %>% 
  mutate(medium = if_else(apc_both == 1 & tank_both==0 & heavy==0, 1, 0)) %>% 
  mutate(inf = if_else(tank_both==0 & apc_both== 0 & medium==0, 1 ,0)) %>% 
  mutate(layer_type = case_when(full_monty==1 & heavy==0 & medium==0 & inf==0 ~ "Full Monty",
                                full_monty==0 & heavy==1 & medium==0 & inf==0 ~ "Heavy",
                                full_monty==0 & heavy==0 & medium==1 & inf==0 ~ "Medium",
                                full_monty==0 & heavy==0 & medium==0 & inf==1 ~ "Infantry focus"))

sqlayers$layer_type <- ordered(sqlayers$layer_type, levels = c("Full Monty",
                                                               "Heavy",
                                                               "Medium",
                                                               "Infantry focus"))

#### END OF CLEAN LAYER DATA ####

#### MERGE CLEAN DATA ####
df <- left_join(df, sqlayers, by = 'layer')

#Put data in order
df <- df %>% select(utc_time:weekend, level, mode, layer, 
                    winner_team, winner_faction, winner_tickets, 
                    loser_team, loser_faction, loser_tickets,
                    dur_min:tick_bucket, tank_both:layer_type, harju, pla, log_count)

#### END OF MERGE CLEAN  DATA ####

#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"))

Now that the data is clean, we are left with 1643 rounds that were played on the TT public server from 2022-10-01 to 2022-12-21. Let us now take a look at these rounds in some more detail.

Maps, modes & layers

In the first section, I want to take a comprehensive look at the maps, modes and layers we play on the main server. The questions I am trying to answer include, but are not limited to:

  • What type of maps do we play on the server?

  • Do the maps we play differ on prime time versus other times of day?

  • How often do maps repeat?

  • What modes dominate the server?

  • Do we underplay certain types of layers (for example,such as layers with helicopters) and overplay others?

A lot of ink has been spilled over these and related questions within the admin team at TT. It is time to get some answers.

Maps

Readers will recall from Chart 7 previous survey that the most requested maps were:

  • Black Coast
  • Goose Bay
  • Mutaha
  • Yehorivka
  • Narva
  • Gorodok

In this subsection, I want to look at what maps are actually played on the server and how this comports to the preferences that the community expressed in the survey.

Overall Sample

#Create dataset
dfc1 <- df %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc1$n)
title <- paste0("Chart 1: Maps played: Oct - Dec 2022"," (Total matches: ", total, ")")

#Build Chart
chart1 <-  ggplot(
  dfc1, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge") + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 200)) +
  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
chart1

We can see that, overall, the maps we play on the server correspond quite well to what is requested. Five out of the top six maps are identical. Based on the results, GooseBay is the only map that is underplayed given the preferences expressed in **the survey**.

Before & After Harju

The survey was conducted in September, before the release of Harju. Since there were no new maps released with the PLA faction on December 7th, 2022, Harju is the only additional map in the pool since the survey

From the charts below, we can see that after Harju was released, it became a very popular map. Most of its popularity came at the expense of Black Coast. Some maps, such as Chora, are overplayed and others, such as GooseBay are underplayed relative to the preferences expressed in the survey. However, most maps are about where they should be. A score of approximately 7/10, which is the score that respondents gave us on the survey, seems justified. It is hard to do better in this area given that people’s map preferences diverge so widely.

2A: Before Harju’s release

#Remove previous
rm(dfc1, chart1, title, total)

#Create dataset for chart

dfc2a <- df %>% filter(harju==0) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc2a$n)
title <- paste0("Chart 2A: Maps played: Pre-Harju"," (Total matches: ", total, ")")

#Build Chart 2a
chart2a <-  ggplot(
  dfc2a, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 100)) +
  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 = 2.75) +
  coord_flip()

#Display
chart2a

2B: After Harju’s release

#Create dataset for chart 
dfc2b <- df %>% filter(harju==1) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc2b$n)
title <- paste0("Chart 2B: Maps played: Post-Harju"," (Total matches: ", total, ")")

#Build Chart
chart2b <-  ggplot(
  dfc2b, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 100)) +
  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 = 2.75) +
  coord_flip()

#Display
chart2b

Maps by time of day & day of week

The charts below present the maps played on the TT server by time of day and day of week for the full sample. The purpose is to show the reader whether our prime-time rotation differs from our late night rotation and whether the maps we play on weekends are different to what we play on weekdays.

The charts below show that the prime-time rotation is more varied and less top heavy than the late night rotation. This is by design as it is internal policy to stick to “safe” layers late night and the popular maps tend to be safe. TT can certainly afford to have more variety in both the pre-prime and prime-time hours because the server is unlikely to die regardless of layer during those hours.

However, one does not simply do something because one can afford to. Judging by the results from chart 7 of the survey, maps such as Anvil, Kamdesh and Mestia are not very popular. It is unclear whether we should play more of these maps for variety’s sake. It is my opinion that we should. This is because the survey is weighted heavily towards experienced players and regulars (see chart 1 of the survey) who tend to dislike maps such as Anvil or Kamdesh. At TT, we strive to cater to everyone, not just a select group. While the opinions of our relatively experienced playerbase should weigh heavily on us (and they do, judging from the charts below), this is not the only thing we should take into consideration.

3A: Prime-Time (5:30 PM to 12 AM EST)

rm(chart2a, chart2b, dfc2a, dfc2b, title, total)

#Create dataset for chart 
dfc3a <- df %>% filter(prime==1) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc3a$n)
title <- paste0("Chart 3A: Maps during prime-time hours"," (Total matches: ", total, ")")

#Build Chart
chart3a <-  ggplot(
  dfc3a, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 100)) +
  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 = 2.75) +
  coord_flip()

#Display
chart3a

3B: Pre-Prime (12 PM EST - 5:30 PM EST)

rm(chart3a, dfc3a, title, total)

#Create dataset for chart 
dfc3b <- df %>% filter(pre_prime==1) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc3b$n)
title <- paste0("Chart 3B: Maps during pre-prime hours"," (Total matches: ", total, ")")

#Build Chart
chart3b <-  ggplot(
  dfc3b, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 75)) +
  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 = 2.75) +
  coord_flip()

#Display
chart3b

3C: Gremlins (12 AM to 12 PM EST)

rm(chart3b, dfc3b, title, total)

#Create dataset for chart 
dfc3c <- df %>% filter(gremlins==1) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc3c$n)
title <- paste0("Chart 3C: Maps during Gremlin hours"," (Total matches: ", total, ")")

#Build Chart
chart3c <-  ggplot(
  dfc3c, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 75)) +
  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 = 2.75) +
  coord_flip()
#Display
chart3c

3D: Weekends (Friday prime time - Sunday)

rm(chart3c, dfc3c, title, total)

#Create dataset for chart 
dfc3d <- df %>% filter(weekend==1) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc3d$n)
title <- paste0("Chart 3C: Maps during Weekends"," (Total matches: ", total, ")")

#Build Chart
chart3d <-  ggplot(
  dfc3d, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 75)) +
  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 = 2.75) +
  coord_flip()

#Display
chart3d

3E: Weekdays (Monday - Friday pre-prime)

rm(chart3d, dfc3d, title, total)

#Create dataset for chart 
dfc3e <- df %>% filter(weekend==0) %>% select(level) %>% count(level, sort = TRUE)
total <- sum(dfc3e$n)
title <- paste0("Chart 3E: Maps during Weekdays"," (Total matches: ", total, ")")

#Build Chart
chart3e <-  ggplot(
  dfc3e, aes(x = reorder(level, n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.3) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 150)) +
  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 = 2.75) +
  coord_flip()

#Display
chart3e

Average rounds per day

The table below presents the average amount of rounds played in each time block.The average duration is also displayed.

rm(chart3e, dfc3e, title, total)

# Build Table (exclude Dec 21 since only 4 rounds of data)

# Average rounds

mapstable <- df %>% select(day, tod) %>% filter(day!= as.Date('2022-12-21')) %>% 
  group_by(day, tod) %>%  summarise(rounds = n()) %>% 
  group_by(tod) %>% summarise(avg_round = round(mean(rounds), 2)) %>% 
    bind_rows(summarise(., across(where(is.numeric), sum),
                         across(where(is.factor), ~'Daily Average')))

# Durations
durtable <-  df %>% 
  filter(day!= as.Date('2022-12-21')) %>% 
  select(tod, dur_min) %>% 
  tbl_summary(by = 'tod', type = everything() ~ "continuous",
  statistic = all_continuous() ~ "{mean}", 
  label = list ('dur_min' ~ 'Average Duration (minutes)'), 
  digits = everything() ~ 2) %>% add_overall() %>% as_tibble()

# Merge
mapstable[1,3] <- durtable[1,3]
mapstable[2,3] <- durtable[1,4]
mapstable[3,3] <- durtable[1,5]
mapstable[4,3] <- durtable[1,2]

# Format
colnames(mapstable) <- c('Time of day', 'Avg. rounds played', 'Avg. duration in minutes')
mapstable$`Avg. duration in minutes` <- as.numeric(mapstable$`Avg. duration in minutes`)

mapstable <- mapstable %>% kable() %>% 
    kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                position = 'center', full_width = TRUE) %>% 
   add_header_above(c("Table 1:  Average rounds per day and durations" = 3))

#Display
mapstable
Table 1: Average rounds per day and durations
Time of day Avg. rounds played Avg. duration in minutes
Pre-Prime 5.71 39.52
Prime Time 9.22 40.00
Gremlins 5.84 44.35
Daily Average 20.77 41.10

On average, we play around 21 rounds per day and 9 of those are played in prime time (between 5:30PM and 12AM EST). Each round lasts approximately 40 minutes.

Average repeats per day

Another thing to take into consideration is how many times per week do we play each map. A lot of heat has been generated by comments about us “playing the same maps over and over” and it is time to shed some light on the matter.

The sample used for these graphs is the “post-Harju” sample containing 901 rounds because I want to reflect the current map pool. I also present two versions:

  • Pre-prime + Prime Time
  • All hours

The reason for this is that including the late night hours will skew the averages because it is internal policy to stick to safer layers past midnight EST. Therefore, to gauge variety, we should primarily look at the hours where policy permits increasing variety.

Chart 4A is instructive. Between the hours of 12 noon EST and midnight EST, no map is, on average, played more than twice. We can see that there are only six maps that we play everyday excluding the late night hours. They are:

  • Narva
  • Harju
  • Mutaha
  • Chora
  • Gorodok
  • Yehorivka

The rest of the maps are played less than once per day on average during the main 12-hour window from noon to midnight EST.

Given that we play approximately 14 rounds a day that are not “late night” rounds (see Table 1), it is good that no map is played more than twice on average in that daily 14 round time frame.

4A: Daily average (excluding late night EST)

rm(mapstable, durtable)

#Create dataset for chart 4

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

title <- paste0("Chart 4A: Mean rounds played per day (excluding late night)")

#Build Chart
chart4a <-  ggplot(
  dfc4a, 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
chart4a

4B: Daily average (all hours)

rm(chart4a, title, dfc4a)

#Create dataset for chart 4

dfc4b <- df %>% mutate(lvl = as.factor(level)) %>% 
  filter(harju==1) %>% 
  select(lvl, day) %>% group_by(day) %>% count(lvl, .drop= FALSE) %>% 
  group_by(lvl) %>%   summarise(mean = round(mean(n), 2))

title <- paste0("Chart 4B: Mean rounds played per day (all hours)")

#Build Chart
chart4b <-  ggplot(
  dfc4b, 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
chart4b

Modes

Even though we are not averse to playing game modes that are not RAAS or AAS on the TT server, those two modes dominate the others in practice by a wide margin. This is not unusual in squad where invasion tends to have its own dedicated servers which play nothing else while TC, Insurgency and Destruction have basically no audience whatsoever.

Chart 5 below and its associated table clearly show that RAAS and AAS combined make up 95% of the rounds played on the server. For all intents and purposes, we are a RAAS/AAS server that rarely forays into other game modes. While I am certain that our community overwhelmingly prefers RAAS and AAS, I am less certain that they prefer it 95% of the time. The survey did not ask about people’s preferences on game modes.

Invasion in squad tends to be played in invasion-only servers. There is a clear split among the servers in squad and it is abundantly clear on which side we fall at TT. My personal opinion is that we should aim to be a 90-10 server instead of the current 95-5 dynamic that exists today because it would create more variety in gameplay. Regardless of disagreements, and there always will be disagreements when it comes to matters of taste, the numbers below tell the story.

5A: Game modes on TT (Pre-Prime + Prime time)

rm(chart4b, title, dfc4b)

#Create chart data

dfc5a <- df %>% select(layer, mode, pre_prime, prime, gremlins) %>%
  filter(prime==1 | pre_prime==1) %>% 
  mutate(mode2 = case_when(mode == "Tanks" ~ "AAS", TRUE ~ mode))

dfc5a$mode2 <- ordered(dfc5a$mode2, levels = c("RAAS", 
                                          "AAS", 
                                          "Invasion", 
                                          "TC",
                                          "Insurgency"))
#Create chart

chart5a <- dfc5a %>% select(mode2) %>% 
  ggplot(aes(x = factor(1), fill = mode2)) +   
  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 5A: Game modes (Excluding late night)") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart5a

#Table 5a

table1 <- dfc5a %>% select(mode2) %>% 
  tbl_summary(type = mode2 ~ "categorical",label = mode2 ~ "Game mode") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 2A: Game modes on TT - Excluding late night') 

#Format

table1 <- table1 %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table1
Table 2A: Game modes on TT - Excluding late night N = 1,178
Game mode
RAAS 672 (57%)
AAS 423 (36%)
Invasion 54 (4.6%)
TC 28 (2.4%)
Insurgency 1 (<0.1%)
Note:
N is the observation count

5B: Game modes on TT (All hours)

rm(chart5a, dfc5a)

#Create chart data

dfc5b <- df %>% select(layer, mode, pre_prime, prime, gremlins) %>% 
  mutate(mode2 = case_when(mode == "Tanks" ~ "AAS", TRUE ~ mode))

dfc5b$mode2 <- ordered(dfc5b$mode2, levels = c("RAAS", 
                                          "AAS", 
                                          "Invasion", 
                                          "TC",
                                          "Insurgency"))
#Create chart

chart5b <- dfc5b %>% select(mode2) %>% 
  ggplot(aes(x = factor(1), fill = mode2)) +   
  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 5B: Game modes (Overall)") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart5b

#Table 2

table2 <- dfc5b %>% select(mode2) %>% 
  tbl_summary(type = mode2 ~ "categorical",label = mode2 ~ "Game mode") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 2B: Game modes on TT - Full sample') 

#Format

table2 <- table2 %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table2
Table 2B: Game modes on TT - Full sample N = 1,643
Game mode
RAAS 1,027 (63%)
AAS 523 (32%)
Invasion 62 (3.8%)
TC 30 (1.8%)
Insurgency 1 (<0.1%)
Note:
N is the observation count

Layers

The community is divided on many things about maps and layers. Some want to play more “big” layers with everything (aka “the full monty”) while others seem to be obsessed with Chora, Kokan and Sumari for reasons that defy good taste and logic. This divide is also present in the admin team at TT and the debate has sometimes generated more heat than light.

Obviously, we cannot satisfy everyone. The best we can hope for is to achieve a compromise for the server we all call home. To do that, we need to look at the matter empirically.

An ideal mix?

Before we get started on what layers TT should play, it is worth asking ourselves what an ideal mix of layers actually is. The answer will vary from person to person. Some might say that the vast majority of what the server should play are big “the full monty”) RAAS layers with a little Chora AAS thrown in for good measure and others might prefer a different mix altogether. ()

To try to get at the question of the “ideal” mix, I take all AAS and RAAS layers in squad (since that is the vast majority of the modes we play) and classify them into four categories:

  • Full monty layers with everything. Both teams have tanks and APCs and helicopters

  • Heavy layers are full monty layers but minus the helicopters.

  • Medium layers where both teams atleast have APCs (defined as 10 ticket or higher vehicles such as BTR-82s and Bradleys) but both teams do not have tanks. If a single team has a tank while the other has an IFV, it will be classified as medium. Typically these type of layers happen with unconventional factions where the Militia might get a T-62 while the conventional faction may get something like a Bradley or BMP-2.

  • Infantry layers with only light vehicles such as CROWs TIGRs, RWS Bulldogs, BTR-80s and the like.

Helicopters can exist in all layers except layers classified as “Heavy.” For example, Talil RAAS v1 is classified as an infantry layer because both teams only get HMMVs or Simirs at most. However, each team also gets an absurd six helicopters.

We can see from the figures below that approximately a third of RAAS/AAS layers can be classified as “full monty.” Layers with tanks make up roughly 45% of all RAAS/AAS layers while infantry focused layers make up around 27%. Layers with at least one helicopter make up approximately 60% of all RAAS/AAS layers. These figures give one a clear idea of the layer mix that exists in the game as a whole.

In the next two subsections, I examine whether these proportions are similar to the proportions that exist on the server.

6A: Layer types in Squad

rm(table1, chart5b, dfc5b, table2)

#Create chart data

dfc6a <- sqlayers %>% 
  filter(Mode == "RAAS" | Mode == "AAS") %>% 
  select(layer_type, full_monty, heavy, medium, inf)

#Create chart

chart6a <- dfc6a %>% select(layer_type) %>% 
  ggplot(aes(x = factor(1), fill = layer_type)) +   
  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 6A: Layer types in Squad", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart6a

#Table 3a

table3a <- dfc6a %>% select(layer_type) %>% 
  tbl_summary(type = 'layer_type' ~ "categorical",label = layer_type ~ "Layer type") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 3A: Layer types in Squad (RAAS/AAS)') 

#Format

table3a <- table3a %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table3a
Table 3A: Layer types in Squad (RAAS/AAS) N = 195
Layer type
Full Monty 62 (32%)
Heavy 26 (13%)
Medium 54 (28%)
Infantry focus 53 (27%)
Note:
N is the observation count

6B: Helicopter layers in Squad

#Create chart data

dfc6b <- sqlayers %>% 
  filter(Mode == "RAAS" | Mode == "AAS") %>% 
  select(layer, helo_both, helo_one, heli)

#Create chart

chart6b <- dfc6b %>% select(heli) %>% 
  ggplot(aes(x = factor(1), fill = heli)) +   
  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 6B: Helicopter layers in Squad", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart6b

#Table 3

table3b <- dfc6b %>% select(heli) %>% 
  tbl_summary(type = 'heli' ~ "categorical",label = heli ~ "Does the layer have a helicopter?") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 3B: Helicopter layers in Squad (RAAS/AAS)') 

#Format

table3b <- table3b %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table3b
Table 3B: Helicopter layers in Squad (RAAS/AAS) N = 195
Does the layer have a helicopter?
Both teams have helicopters 103 (53%)
Only one team has a helicopter 15 (7.7%)
No helicopters on layer 77 (39%)
Note:
N is the observation count

Helicopter layers at TT

Now that we have some idea of what an “ideal mix” should be, we can look at actual data from the server. Overall, we are within acceptable parameters when it comes to layers with helicopters. Approximately 60% of all RAAS/AAS layers in squad have helicopters (see Chart 6B) and we play layers with helicopters approximately 55% of the time (see Chart 7C below). The slight disparity comes from the fact that we underplay layers where only one side has a helicopter. This is because those layers tend to be unpopular RAAS/AAS layers where one side is an unconventional faction matched against a conventional opponent.

We can see that during prime time, we play layers with helicopters slightly more than half the time. This means that we slightly underplay helicopter layers relative to their abundance (see Chart 6B) on prime time. This can be rectified by playing 1-2 more helicopter layers on prime time than we do currently if we want to match what is in the game.

7A: Heli layers (Excluding late night)

rm(chart6a, chart6b, dfc6a, dfc6b, table3a, table3b)

#Create chart data

dfc7a <- df %>% filter(mode == "RAAS" | mode == "AAS") %>% 
  filter(gremlins!=1) %>% 
  select(layer, helo_both, helo_one, heli)

#Create chart

chart7a <- dfc7a %>% select(heli) %>% 
  ggplot(aes(x = factor(1), fill = heli)) +   
  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 7A: Helicopter layers at TT", subtitle = "RAAS & AAS only - Excluding late night") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart7a

#Table 4

table4a <- dfc7a %>% select(heli) %>% 
  tbl_summary(type = 'heli' ~ "categorical",label = heli ~ "Does the layer have a helicopter?") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 4A: Helicopter layers at TT excluding late night (RAAS/AAS)') 

#Format

table4a <- table4a %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table4a
Table 4A: Helicopter layers at TT excluding late night (RAAS/AAS) N = 1,092
Does the layer have a helicopter?
Both teams have helicopters 512 (47%)
Only one team has a helicopter 41 (3.8%)
No helicopters on layer 539 (49%)
Note:
N is the observation count

7B: Heli layers (Prime Time only)

#Create chart data

dfc7b <- df %>% filter(mode == "RAAS" | mode == "AAS") %>% filter(prime==1) %>% 
  select(layer, helo_both, helo_one, heli)

#Create chart

chart7b <- dfc7b %>% select(heli) %>% 
  ggplot(aes(x = factor(1), fill = heli)) +   
  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 7B: Helicopter layers at TT", subtitle = "RAAS & AAS only - Prime time only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart7b

#Table 4

table4b <- dfc7b %>% select(heli) %>% 
  tbl_summary(type = 'heli' ~ "categorical",label = heli ~ "Does the layer have a helicopter?") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 4: Helicopter layers at TT during prime time (RAAS/AAS)') 

#Format

table4b <- table4b %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table4b
Table 4: Helicopter layers at TT during prime time (RAAS/AAS) N = 677
Does the layer have a helicopter?
Both teams have helicopters 334 (49%)
Only one team has a helicopter 26 (3.8%)
No helicopters on layer 317 (47%)
Note:
N is the observation count

7C: Heli layers (Overall)

#Create chart data

dfc7c <- df %>% filter(mode == "RAAS" | mode == "AAS") %>% 
  select(layer, helo_both, helo_one, heli)

#Create chart

chart7c <- dfc7c %>% select(heli) %>% 
  ggplot(aes(x = factor(1), fill = heli)) +   
  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 7C: Helicopter layers at TT (Overall)", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart7c

#Table 4

table4c <- dfc7c %>% select(heli) %>% 
  tbl_summary(type = 'heli' ~ "categorical",label = heli ~ "Does the layer have a helicopter?") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 4C: Helicopter layers at TT (RAAS/AAS)') 

#Format

table4c <- table4c %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table4c
Table 4C: Helicopter layers at TT (RAAS/AAS) N = 1,547
Does the layer have a helicopter?
Both teams have helicopters 788 (51%)
Only one team has a helicopter 48 (3.1%)
No helicopters on layer 711 (46%)
Note:
N is the observation count

Layer types at TT

We should also look at the distribution of the types of layers we play on the server as categorised earlier. If we compare the charts below to what exists in Squad as a whole (see Chart 6A), there is a striking similarity. It turns out that TT is in almost perfect balance.

For example, according to Chart 6A, approximately a third of RAAS/AAS layers in Squad can be classified as “Full monty” layers - ie layers with everything on them. As it happens, TT plays full monty layers almost exactly a third of the time on the server no matter what time of day you look at. In fact, if you look at the overall picture, we actually slightly over play “full monty” layers (38% in Chart 8C versus 32% in Chart 6A). The proportions for heavy, medium and infantry focused layers are also almost exactly in line with what exists in the game.

TT is, in a sense, a pure RAAs/AAS server in that we do not discriminate against any particular type of RAAS/AAS layer. The harshest critique that one can level at us with our layer choice is that we slightly discriminate against the Militia and Insurgent factions in Squad. Many layers that feature these factions tend to be invasion layers, and, as we discussed above, TT tends to not play invasion very often. There are very few quality RAAS/AAS layers with unconventional factions.

8A: Layer types (Excluding late night)

rm(chart7a, chart7b, chart7c, dfc7a, dfc7b, dfc7c, table4a, table4b, table4c)

#Create chart data

dfc8a <- df %>% 
  filter(mode == "RAAS" | mode == "AAS") %>% 
  filter(gremlins!=1) %>% 
  select(layer, layer_type)

#Create chart

chart8a <- dfc8a %>% select(layer_type) %>% 
  ggplot(aes(x = factor(1), fill = layer_type)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Layer Types", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 8A: Layer types at TT (Excluding late night)", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart8a

#Table 5a

table5a <- dfc8a %>% select(layer_type) %>% 
  tbl_summary(type = 'layer_type' ~ "categorical",label = layer_type ~ "Layer Type") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 5A: Layer types at TT excluding late night (RAAS/AAS)') 

#Format

table5a <- table5a %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table5a
Table 5A: Layer types at TT excluding late night (RAAS/AAS) N = 1,092
Layer Type
Full Monty 351 (32%)
Heavy 152 (14%)
Medium 330 (30%)
Infantry focus 259 (24%)
Note:
N is the observation count

8B: Layer types (Prime Time)

#Create chart data

dfc8b <- df %>% 
  filter(mode == "RAAS" | mode == "AAS") %>% 
  filter(prime==1) %>% 
  select(layer, layer_type)

#Create chart

chart8b <- dfc8b %>% select(layer_type) %>% 
  ggplot(aes(x = factor(1), fill = layer_type)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Layer Types", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 8B: Layer types at TT (Prime Time)", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart8b

#Table 5b

table5b <- dfc8b %>% select(layer_type) %>% 
  tbl_summary(type = 'layer_type' ~ "categorical",label = layer_type ~ "Layer Type") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 5B: Layer types at TT excluding late night (RAAS/AAS)') 

#Format

table5b <- table5b %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table5b
Table 5B: Layer types at TT excluding late night (RAAS/AAS) N = 677
Layer Type
Full Monty 239 (35%)
Heavy 86 (13%)
Medium 206 (30%)
Infantry focus 146 (22%)
Note:
N is the observation count

8C: Layer types (Overall)

#Create chart data

dfc8c <- df %>% 
  filter(mode == "RAAS" | mode == "AAS") %>% 
  select(layer, layer_type)

#Create chart

chart8c <- dfc8c %>% select(layer_type) %>% 
  ggplot(aes(x = factor(1), fill = layer_type)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Layer Types", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 8C: Layer types at TT (Overall)", subtitle = "RAAS & AAS only") + 
  theme(plot.title = element_text(hjust = 0.5), plot.subtitle = element_text(hjust = 0.5))

#Display
chart8c

#Table 5c

table5c <- dfc8c %>% select(layer_type) %>% 
  tbl_summary(type = 'layer_type' ~ "categorical",label = layer_type ~ "Layer Type") %>%  
  modify_footnote(everything() ~ NA) %>% 
  modify_header(label = 'Table 5C: Layer types at TT (RAAS/AAS)') 

#Format

table5c <- table5c %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("N is the observation count"))


# Display
table5c
Table 5C: Layer types at TT (RAAS/AAS) N = 1,547
Layer Type
Full Monty 589 (38%)
Heavy 199 (13%)
Medium 405 (26%)
Infantry focus 354 (23%)
Note:
N is the observation count

Balance

We also have data on wins, losses and ticket differentials and we can use this to assess the state of balance in the server. Before we do that, it is important to state TacTrig’s balance policy in clear terms. I quote the following directly from the server rules

Tactical Triggernometry - Balance policy (from the rules)

At TacTrig, we take team balance seriously. As such we expect our players to abide by the following policy:

  • All players, especially server regulars, are expected to help with balance on the server. Switching teams to play with friends is understood, switching teams because your team sucks is not acceptable.

Furthermore, to promote balanced gameplay and a positive player experience:

  • If a team suffers two heavy defeats (200+ tickets), 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 will use the “two heavy defeats” heuristic as a yardstick later.

Average Ticket differentials

First, we can look at average ticket differentials throughout the course of a regular day. The figures below sheds some light on this.

I count only RAAS, AAS and TC rounds.

rm(chart8a, chart8b, chart8c, dfc8a, dfc8b, dfc8c, table5a, table5b, table5c)

# Build data
dfc9 <- df %>% 
  select(tick_bucket, prime, mode, tick_diff) %>% 
  filter(mode == "AAS" | mode=="RAAS")

#Create chart

chart9 <- dfc9 %>% select(tick_bucket) %>% 
  ggplot(aes(x = factor(1), fill = tick_bucket)) +   
  geom_bar(width = 1)+ coord_polar("y") +  blank_theme + 
  theme(axis.text.x=element_blank()) + 
  scale_fill_brewer("Ticket differentials", palette = "Set1") + 
  theme(axis.text.y=element_blank()) + 
  labs(title = "Chart 9: Ticket differentials for RAAS/AAS") + 
  theme(plot.title = element_text(hjust = 0.5))

#Display
chart9

rm(chart9)

#Table 3
dfc9 <- dfc9 %>% mutate(prime2 = case_when(prime==1 ~ "Yes",
                                           prime==0 ~ "No"))

dfc9$prime2 <- ordered(dfc9$prime2, levels = c("Yes", "No"))

#Build

table6 <- dfc9 %>% select(tick_bucket, prime2) %>% 
  tbl_cross(row = 'tick_bucket', col = 'prime2', percent="column",
            label = list('tick_bucket' ~ 'Ticket Differentials','prime2' ~ 'Prime Time')) %>% 
  add_p() %>% modify_footnote(everything() ~ NA)


#Format

table6 <- table6 %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("P-value calculated using Pearson's Chi-squared test")) %>% 
  add_header_above(c("Table 6: Ticket differentials Prime time vs other times (RAAS/AAS)" = 5))


# Display
table6
Table 6: Ticket differentials Prime time vs other times (RAAS/AAS)
Prime Time
Yes No Total p-value
Ticket Differentials 0.3
Below 100 221 (33%) 251 (29%) 472 (31%)
101 to 150 122 (18%) 153 (18%) 275 (18%)
151 to 200 113 (17%) 144 (17%) 257 (17%)
Above 200 221 (33%) 322 (37%) 543 (35%)
Total 677 (100%) 870 (100%) 1,547 (100%)
Note:
P-value calculated using Pearson’s Chi-squared test

Approximately two thirds of regular (non-invasion/insurgency) rounds played on TT are not “rolls” (defined as ticket differential being above 200). A third of regular rounds are very close with the ticket differential being below 100.

In the table above, I also categorise rounds by whether they are played during prime time or not. The reasoning behind this is that prime time is when most regulars and admins play on the server and it is when the server is at its most competitive gameplay wise. A chi-squared test reveals that ticket differentials are not significantly different from one another during prime and non-prime hours.

Rather than binning rounds into a category, we can also check for statistical differences in the raw ticket differentials. The chart below plots out the distributions of these raw differentials for prime and non-prime hours. As before, I only count AAS, RAAS and TC rounds.

rm(table6)

#Calculate P-value
pval <- dfc9 %>% select(tick_diff, prime2) %>% tbl_summary(by="prime2") %>% add_difference() %>% as_tibble()
pval <- pval[1,6]
caption <- paste("P-value calculated using Welch's Two Sample t-test", pval, sep = " = ")

# Build chart

chart10<- dfc9 %>% 
  ggplot(aes(x=tick_diff)) + 
  geom_density(aes(fill = prime2), alpha = 0.5, colour = NA) + 
  scale_fill_manual(values = c("#ff0000", "#0000FF")) + 
  theme_classic() +
  labs(title = "Chart 10: Distribution of ticket differentials", caption = caption, fill = "Prime time?") +
  xlab("Ticket Differentials") + ylab("Density") +
  theme(plot.title = element_text(hjust = 0.5)) + 
  theme(plot.caption = element_text(hjust = 0.5)) +
  theme(legend.position = "right")


chart10

We can see that prime time rounds tend to be very similar to non-prime rounds. The P-value shows no statistical difference in ticket differentials between prime time and non prime time rounds. The chart above confirms the results of Table 3, namely that prime time rounds really are not that different from non-prime rounds when it comes to the ticket differentials.

We can also check whether ticket differentials are significantly different from one another across the layer types we defined earlier.. The table below presents the results:

#Table 7
dft10 <- df %>% select(layer_type, tick_bucket, mode) %>% filter(mode=="AAS" | mode=="RAAS")

#Build

table7 <- dft10 %>% select(tick_bucket, layer_type) %>% 
  tbl_cross(row = 'tick_bucket', col = 'layer_type', percent="column",
            label = list('tick_bucket' ~ 'Ticket Differentials','layer_type' ~ 'Layer Type')) %>% 
  add_p() %>% modify_footnote(everything() ~ NA)


#Format

table7 <- table7 %>% as_kable_extra() %>% 
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), 
                position = 'center', full_width = TRUE) %>% 
  footnote(general = c("P-value calculated using Pearson's Chi-squared test")) %>% 
  add_header_above(c("Table 7: Ticket differentials (Layer Types - RAAS/AAS only)" = 7))


# Display
table7
Table 7: Ticket differentials (Layer Types - RAAS/AAS only)
Layer Type
Full Monty Heavy Medium Infantry focus Total p-value
Ticket Differentials 0.9
Below 100 170 (29%) 62 (31%) 128 (32%) 112 (32%) 472 (31%)
101 to 150 103 (17%) 39 (20%) 70 (17%) 63 (18%) 275 (18%)
151 to 200 92 (16%) 34 (17%) 67 (17%) 64 (18%) 257 (17%)
Above 200 224 (38%) 64 (32%) 140 (35%) 115 (32%) 543 (35%)
Total 589 (100%) 199 (100%) 405 (100%) 354 (100%) 1,547 (100%)
Note:
P-value calculated using Pearson’s Chi-squared test

It appears that ticket differentials follow a remarkably consistent pattern regardless of layer type. For example, approximately a third of rounds, regardless of layer type, are rounds decided by fewer than 100 tickets. The proportions in the other ticket differential categories also look very similar. The results of a simple Chi-squared test are presented in the first row in the table above and the results show that there are no statistically significant differences in ticket differentials across all layer types.

Streaks

A crucial part of our balance policy is to avoid and correct for “streakiness” where one team just dominates another round after round. In this section, I look at streaks and measure them in the dataset. I am trying to answer a few questions:

  • How well do we adhere to our stated balance policy?

  • What happens if we change the definition of a streak from “two rounds of 200+ ticket rolls” to something else?

Defined as per TT Ruleset

rm(chart10, pval, caption, dfc9, dft10, table7)

#Overall
all_streaks <- df %>% 
  select(est_time, log_count, day, winner_team, tick_diff, tick_bucket, dur_min, time_diff)

# Streaks (Raw)
all_streaks<- all_streaks %>% 
  mutate(win_count = if_else(winner_team != lag(winner_team) & time_diff<=120, 1, 0)) %>% 
  mutate(win_count = replace_na(win_count, 0)) %>% 
  mutate(raw_streak = if_else(win_count!=0, as.numeric(sequence(rle(win_count)$lengths)), 0) + 1)
  
# Streaks (150 standard)
all_streaks <- all_streaks %>% 
  mutate(win_count_150 = if_else(winner_team != lag(winner_team) & tick_diff>150 & time_diff<=120, 1, 0)) %>% 
  mutate(win_count_150 = replace_na(win_count_150, 0)) %>% 
  mutate(streak_150 = if_else(win_count_150!=0, as.numeric(sequence(rle(win_count_150)$lengths)), 0) + 1)

# Streaks (TT Standard)
all_streaks <- all_streaks %>% 
  mutate(win_count_tt = if_else(winner_team != lag(winner_team) & tick_diff>200 & time_diff<=120, 1, 0)) %>% 
  mutate(win_count_tt = replace_na(win_count_tt, 0)) %>% 
  mutate(tt_streak = if_else(win_count_tt!=0, as.numeric(sequence(rle(win_count_tt)$lengths)), 0) + 1)


#Build Chart data (for all streak charts)

#TT standard
dfc11 <- all_streaks %>% select(tt_streak) %>% count(tt_streak, sort= TRUE)
total <- nrow(all_streaks)
title <- paste0("Chart 11: Streaks defined as per TT ruleset"," (Total matches: ", total, ")")

#150 standard
dfc11a <- all_streaks %>% select(streak_150) %>% count(streak_150, sort= TRUE)

#Raw
dfc11b <- all_streaks %>% select(raw_streak) %>% count(raw_streak, sort= TRUE)

# 0_00_0's "weight of previous round" argument
dfc11 <- dfc11 %>%  mutate(alt = ifelse(tt_streak!= nrow(dfc11),  
                                        tt_streak*(n-lead(n)), tt_streak*n))

dfc11a <- dfc11a %>%  mutate(alt = ifelse(streak_150!= nrow(dfc11a),  
                                        streak_150*(n-lead(n)), streak_150*n))

dfc11b <- dfc11b %>%  mutate(alt = ifelse(raw_streak!= nrow(dfc11b),  
                                        raw_streak*(n-lead(n)), raw_streak*n))

#Build Chart
chart11 <-  ggplot(
  dfc11, aes(x = reorder(tt_streak, -n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.75) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 1400)) +
  xlab("Streak Length") +
  ylab("Frequency count") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(vjust= -1, hjust = 0.5, size = 2.75)

#Display
chart11

Each streak of number N will also, by definition, include a streak of N-1. For example, to reach a streak of 6, the preceding round will be counted in the total number of streaks of 5 and the round preceding that will be counted in the total number of streaks of 4 and so on. Therefore, any time the streak crosses 2, we are in violation of TT’s balance policy.

Out of 1643 rounds we were in violation of our policy 74 times. This translates a rate of approximately 4.5% - a negligible amount.

By and large, we have adhered to our stated balance policy. This could be because we are excellent balancers or because our standards are too low. Next, I look at what happens if I change the standard.

Other definitions

In the charts below, I calculate streaks in two more ways.

  • Change the threshold of “roll” from 200 to 150

  • Count consecutive wins, regardless of ticket count. This is equivalent to setting the threshold to 0.

If we use a threshold of 150, we can see from the chart below there are 119 times in a sample of 1643 rounds that we get three consecutive wins by one team of more than 150 tickets. The translates to a rate of approximately 7.24% Therefore, even if TT lowered its policy threshold to 150 tickets, we would still be in compliance the vast majority of the time.

If we use the simplest definition of counting consecutive wins, regardless of ticket count, things change quite a bit. There have even been two extreme cases where a single team won twelve consecutive rounds! Regardless, when performing the same comparison as above, we can see that there are 241 times that a team won three consecutive rounds in a row - a rate of approximately 14.67%

We can also use Chart 11B below to answer a simple, yet important, question - What is the probability that a team will win the next round given that it has won the round before. Mathematically, this can be defined as:

\[ P(Win\;next\;round\;|\;Won\; Previous ) = \frac{P(Streak \;of\;1 )}{P(No\;Streak)} \] If we do this calculation using the simplest definition of a streak (ie - simple consecutive wins), this probability works out to be approximately 59.41%. This means that a team will win the current round roughly 59% of the time if it won the prior one. Not quite 50/50; a team does have some persistence.

11A: Threshold of 150

rm(chart11, title)

#Setup
title <- paste0("Chart 11A: Streaks defined by two rolls of 150+ tickets"," (Total matches: ", total, ")")

#Build Chart
chart11a <-  ggplot(
  dfc11a, aes(x = reorder(streak_150, -n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.75) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 1200)) +
  xlab("Streak Length") +
  ylab("Frequency count") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(vjust= -1, hjust = 0.5, size = 2.75)

#Display
chart11a

11B: Simple consecutive wins

rm(chart11a, title)

#Setup
title <- paste0("Chart 11B: Streaks defined by consecutive wins"," (Total matches: ", total, ")")

#Build Chart
chart11b <-  ggplot(
  dfc11b, aes(x = reorder(raw_streak, -n), y = n, label = n)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.75) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 800)) +
  xlab("Streak Length") +
  ylab("Frequency count") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(vjust= -1, hjust = 0.5, size = 2.75)

#Display
chart11b

Alternative counting methods

The way that streaks are counted above means that in a three round streak, only the third round would be counted as a “3” and if the subsequent round was also a win for the streaking team, that round would be counted in “4.” However, some might argue that “all rounds of a streak should be counted.” There are arguments for and against this. For instance, to measure policy violations, one cannot count the first two rounds because the policy allows for a two round streak.

A flaw in this method is that it implies “all rounds in a 5 (or any) round streak count equally.” This means that players in the 2nd round somehow have “foreknowledge” and can see into the future that they are about to lose the next 3. It is not really possible to say that the cumulative weight of losing previous rounds exists in the same way in the third round of a 3 round streak (when the prior 2 rounds have been heavy losses) versus the first round of that same streak (when players do not know what will happen in the next 2 rounds) - but this method of counting treats all rounds in the streak as equivalent.

A counterargument is that when we count “all rounds” we get an idea of the “balance environment” on the server. From Chart 9 we already know that approximately a third of the rounds have ticket differentials exceeding 200. Some portion of that third are going to be part of streaks. The chart below finds out how many.

# 0_00_0s weight argument

#Setup
title <- paste0("Chart 12: Streaks of 200 counting all rounds"," (Total matches: ", total, ")")

#Build Chart
chart12 <-  ggplot(
  dfc11, aes(x = reorder(tt_streak, -alt), y = alt, label = alt)) +
  geom_col(fill="#99ccff", position = "dodge", width = 0.75) + 
  theme_classic() +
  scale_y_continuous(limits = c(0, 1000)) +
  xlab("Streak Length - All rounds") +
  ylab("Frequency count") +
  labs(title = title) +
  theme(plot.title = element_text(hjust = 0.5)) +
  geom_text(vjust= -1, hjust = 0.5, size = 2.75)

#Display
chart12

Going by this measurement, we can see that a total of 162 + 60 +15 + 7 + 6 = 250 rounds were a part of a streak greater than 2. This translates to approximately 15%. Again, I must stress that this method of counting should be interpreted very carefully. This is not saying that we are in “violation” of our policy 15% of the time - our violation rate is measured in Chart 11 at approximately 4.5%. The correct way to interpret this 15% number is that this is the proportion of times when the balance “environment” has become “bad” even though it is not a violation of our publicly stated guidelines.

Acknowledgements

A big thank you to ect0s for making the data available in an easily accessible format without which this report would not have been possible. I would also like to thank the admin team at TT for their continued feedback throughout the writing of this report.

Any errors that remain are mine alone. All questions and comments about this report should be forwarded to me, Randy Newman.