Below are graphics that describe review data from SteamSpy on a sample of 20 developers, 10 indie and 10 AAA, with 2 games that best represent them. I curated the sample based on well-known developers and their best-known games. I then checked which developers have data on SteamSpy for those games, and I adjusted the sample accordingly. The first boxplot compares the positive review percentage of each category of developer. The second boxplot compares the total review volume for both categories. The dots in both plots are the sampled developers. The review volume boxplot uses a logarithmic scale because of the number of reviews. The chart is a developer ranking by average positive review percentage. The bubble chart shows each developer’s review volume and positive review percentage. The bigger the bubble, the more reviews that developer has. The table shows extensive data on each game in the sample. It shows the game name, its developer, the category it falls under, the positive, negative, and total reviews, and its positive and negative review percentages.

Code

Below is the code used to produce the results.

###############################################################################
# 01. LOAD LIBRARIES
###############################################################################

library(httr)
library(jsonlite)
library(dplyr)
library(ggplot2)
library(purrr)
library(tidyr)
library(broom)
library(plotly)
library(htmlwidgets)
library(scales)
library(DT)

###############################################################################
# 02. GAME SAMPLE: 40 GAMES FROM 20 DEVELOPERS
###############################################################################

games <- tibble::tribble(
  
  ~appid, ~game_name, ~developer, ~category,
  
  # =====================================================================
  # AAA: 20 GAMES FROM 10 DEVELOPERS
  # =====================================================================
  
  # 1. CD PROJEKT RED
  292030,  "The Witcher 3",          "CD PROJEKT RED", "AAA",
  1091500, "Cyberpunk 2077",         "CD PROJEKT RED", "AAA",
  
  # 2. FromSoftware
  1245620, "Elden Ring",             "FromSoftware", "AAA",
  374320,  "Dark Souls III",         "FromSoftware", "AAA",
  
  # 3. Capcom
  601150,  "Devil May Cry 5",        "Capcom", "AAA",
  883710,  "Resident Evil 2",        "Capcom", "AAA",
  
  # 4. Bethesda
  489830,  "Skyrim Special Edition", "Bethesda", "AAA",
  377160,  "Fallout 4",              "Bethesda", "AAA",
  
  # 5. Larian Studios
  1086940, "Baldur's Gate 3",        "Larian", "AAA",
  435150,  "Divinity Original Sin 2","Larian", "AAA",
  
  # 6. Firaxis Games
  289070,  "Civilization VI",       "Firaxis", "AAA",
  8930,    "Civilization V",        "Firaxis", "AAA",
  
  # 7. Paradox Interactive
  281990,  "Stellaris",             "Paradox", "AAA",
  394360,  "Hearts of Iron IV",     "Paradox", "AAA",
  
  # 8. Obsidian Entertainment
  962130,  "Grounded",              "Obsidian Entertainment", "AAA",
  291650,  "Pillars of Eternity",   "Obsidian Entertainment", "AAA",
  
  # 9. Frontier Developments
  703080,  "Planet Zoo",            "Frontier Developments", "AAA",
  493340,  "Planet Coaster",        "Frontier Developments", "AAA",
  
  # 10. Valve
  220,     "Half-Life 2",           "Valve", "AAA",
  620,     "Portal 2",              "Valve", "AAA",
  
  
  # =====================================================================
  # INDIE: 20 GAMES FROM 10 DEVELOPERS
  # =====================================================================
  
  # 1. Supergiant Games
  1145360, "Hades",                 "Supergiant", "Indie",
  237930,  "Transistor",            "Supergiant", "Indie",
  
  # 2. Hopoo Games
  632360,  "Risk of Rain 2",        "Hopoo Games", "Indie",
  248820,  "Risk of Rain",          "Hopoo Games", "Indie",
  
  # 3. Klei Entertainment
  219740,  "Don't Starve",          "Klei", "Indie",
  457140,  "Oxygen Not Included",   "Klei", "Indie",
  
  # 4. Frictional Games
  57300,  "Amnesia: The Dark Descent", "Frictional Games", "Indie",
  282140, "SOMA",                      "Frictional Games", "Indie",
  
  # 5. Sabotage Studio
  1244090, "Sea of Stars",          "Sabotage", "Indie",
  764790,  "The Messenger",         "Sabotage", "Indie",
  
  # 6. Subset Games
  212680, "FTL: Faster Than Light", "Subset Games", "Indie",
  590380, "Into the Breach",        "Subset Games", "Indie",
  
  # 7. Matt Makes Games
  504230,  "Celeste",               "Matt Makes Games", "Indie",
  251470,  "TowerFall Ascension",   "Matt Makes Games", "Indie",
  
  # 8. Toby Fox
  391540,  "Undertale",             "Toby Fox", "Indie",
  1671210, "Deltarune",             "Toby Fox", "Indie",
  
  # 9. Red Hook Studios
  262060,  "Darkest Dungeon",      "Red Hook Studios", "Indie",
  1940340, "Darkest Dungeon II",   "Red Hook Studios", "Indie",
  
  # 10. Chucklefish
  211820,  "Starbound",              "Chucklefish", "Indie",
  607050,  "Wargroove",              "Chucklefish", "Indie"
  
)

###############################################################################
# SAMPLE VALIDATION
###############################################################################

cat("\n")
cat("====================================\n")
cat("SAMPLE SIZE VALIDATION\n")
cat("====================================\n")

print(
  games %>%
    group_by(category) %>%
    summarise(
      developers = n_distinct(developer),
      games = n(),
      .groups = "drop"
    )
)

cat("\nTotal games:", nrow(games), "\n")

cat(
  "Total developers:",
  n_distinct(games$developer),
  "\n"
)

print(
  games %>%
    count(developer, category)
)

stopifnot(
  nrow(games) == 40,
  n_distinct(games$developer) == 20,
  all(table(games$category) == 20),
  all(games %>% count(developer) %>% pull(n) == 2)
)

###############################################################################
# 03. STEAMSPY HELPER
###############################################################################

get_steamspy_data <- function(appid){
  
  url <- paste0(
    "https://steamspy.com/api.php?request=appdetails&appid=",
    appid
  )
  
  result <- tryCatch(
    
    fromJSON(
      content(
        GET(url),
        "text",
        encoding = "UTF-8"
      )
    ),
    
    error = function(e) NULL
    
  )
  
  result
}

###############################################################################
# 04. DOWNLOAD DATA
###############################################################################

review_data <- map_dfr(
  
  seq_len(nrow(games)),
  
  function(i){
    
    cat(
      "Downloading:",
      games$game_name[i],
      "\n"
    )
    
    spy <- get_steamspy_data(
      games$appid[i]
    )
    
    if(is.null(spy))
      return(NULL)
    
    positive_reviews <- suppressWarnings(
      as.numeric(spy$positive)
    )
    
    negative_reviews <- suppressWarnings(
      as.numeric(spy$negative)
    )
    
    total_reviews <-
      positive_reviews +
      negative_reviews
    
    tibble(
      appid = games$appid[i],
      game_name = games$game_name[i],
      developer = games$developer[i],
      category = games$category[i],
      positive_reviews = positive_reviews,
      negative_reviews = negative_reviews,
      total_reviews = total_reviews,
      owners = spy$owners
    )
    
  }
  
)

###############################################################################
# 04A. DOWNLOAD VALIDATION
###############################################################################

cat("\n")
cat("====================================\n")
cat("DOWNLOAD VALIDATION\n")
cat("====================================\n")

cat(
  "Games in original sample:",
  nrow(games),
  "\n"
)

cat(
  "Games successfully downloaded:",
  nrow(review_data),
  "\n"
)

missing_games <- anti_join(
  games,
  review_data,
  by = "appid"
)

if(nrow(missing_games) > 0){
  
  cat("\nGames missing from SteamSpy data:\n")
  
  print(missing_games)
  
}

if(nrow(review_data) != 40){
  
  stop(
    "The sample is incomplete. Check missing SteamSpy API responses."
  )
  
}
###############################################################################
# 05. CLEAN DATA
###############################################################################

review_data <- review_data %>%
  
  filter(
    !is.na(total_reviews),
    total_reviews > 0,
    !is.na(positive_reviews),
    !is.na(negative_reviews)
  ) %>%
  
  mutate(
    
    positive_pct =
      (positive_reviews / total_reviews) * 100,
    
    weighted_reputation =
      positive_pct * log10(total_reviews),
    
    log_reviews =
      log10(total_reviews),
    
    low_review_volume =
      total_reviews < 10000
    
  )

###############################################################################
# SAMPLE SIZE CHECK
###############################################################################

cat("\n")
cat("====================================\n")
cat("FINAL ANALYTICAL SAMPLE\n")
cat("====================================\n")

print(
  review_data %>%
    group_by(category) %>%
    summarise(
      games = n(),
      developers = n_distinct(developer),
      .groups = "drop"
    )
)

cat(
  "\nTotal observations:",
  nrow(review_data),
  "\n"
)

cat(
  "Games below 10,000 reviews:",
  sum(review_data$low_review_volume),
  "\n"
)

###############################################################################
# 06. GAME-LEVEL SUMMARY
###############################################################################

game_summary <- review_data %>%
  
  group_by(category) %>%
  
  summarise(
    
    games = n(),
    
    mean_positive_pct =
      mean(positive_pct),
    
    median_positive_pct =
      median(positive_pct),
    
    iqr_positive_pct =
      IQR(positive_pct),
    
    mean_reviews =
      mean(total_reviews),
    
    median_reviews =
      median(total_reviews),
    
    iqr_reviews =
      IQR(total_reviews),
    
    mean_weighted_reputation =
      mean(weighted_reputation),
    
    median_weighted_reputation =
      median(weighted_reputation),
    
    .groups = "drop"
    
  )

print(game_summary)

###############################################################################
# 07. DEVELOPER-LEVEL DATASET
###############################################################################

developer_summary <- review_data %>%
  
  group_by(
    developer,
    category
  ) %>%
  
  summarise(
    
    games = n(),
    
    total_positive_reviews =
      sum(
        positive_reviews,
        na.rm = TRUE
      ),
    
    total_negative_reviews =
      sum(
        negative_reviews,
        na.rm = TRUE
      ),
    
    total_reviews =
      sum(
        total_reviews,
        na.rm = TRUE
      ),
    
    developer_positive_pct =
      (
        total_positive_reviews /
          total_reviews
      ) * 100,
    
    median_game_positive_pct =
      median(
        positive_pct,
        na.rm = TRUE
      ),
    
    avg_reviews_per_game =
      total_reviews / games,
    
    log_reviews =
      log10(total_reviews),
    
    weighted_reputation =
      developer_positive_pct *
      log10(total_reviews),
    
    .groups = "drop"
    
  )

print(developer_summary)

###############################################################################
# DEVELOPER SAMPLE VALIDATION
###############################################################################

cat("\n")
cat("====================================\n")
cat("DEVELOPER SAMPLE VALIDATION\n")
cat("====================================\n")

print(
  developer_summary %>%
    select(
      developer,
      category,
      games,
      total_reviews,
      developer_positive_pct
    )
)

stopifnot(
  nrow(developer_summary) == 20,
  all(developer_summary$games == 2),
  sum(developer_summary$category == "AAA") == 10,
  sum(developer_summary$category == "Indie") == 10
)
###############################################################################
# 08. ASSUMPTION CHECKING
###############################################################################

cat("\n")
cat("====================================\n")
cat("SHAPIRO-WILK TESTS\n")
cat("====================================\n")

aaa_scores <- developer_summary %>%
  filter(category == "AAA") %>%
  pull(developer_positive_pct)

indie_scores <- developer_summary %>%
  filter(category == "Indie") %>%
  pull(developer_positive_pct)

print(shapiro.test(aaa_scores))
print(shapiro.test(indie_scores))

###############################################################################
# 09. DEVELOPER-LEVEL WILCOXON TESTS
###############################################################################

cat("\n")
cat("====================================\n")
cat("DEVELOPER LEVEL COMPARISONS\n")
cat("====================================\n")

sentiment_test <- wilcox.test(
  developer_positive_pct ~ category,
  data = developer_summary,
  exact = FALSE
)

review_volume_test <- wilcox.test(
  total_reviews ~ category,
  data = developer_summary,
  exact = FALSE
)

print(sentiment_test)
print(review_volume_test)

###############################################################################
# 10. DEVELOPER-LEVEL REGRESSION
###############################################################################

cat("\n")
cat("====================================\n")
cat("DEVELOPER LEVEL REGRESSION\n")
cat("DV = POSITIVE REVIEW %\n")
cat("====================================\n")

model <- lm(
  
  developer_positive_pct ~
    
    category +
    
    log_reviews,
  
  data = developer_summary
  
)
summary(model)

regression_table <- broom::tidy(model)

print(regression_table)

###############################################################################
# 11. INTERACTIVE VISUALIZATIONS
###############################################################################

# ============================================================================
# Interactive Figure 1
# Developer-Level Positive Review %
# ============================================================================

p1 <- ggplot(
  
  developer_summary,
  
  aes(
    
    x = category,
    y = developer_positive_pct,
    fill = category,
    
    text = paste(
      
      "Developer:", developer,
      
      "<br>Category:", category,
      
      "<br>Positive %:",
      round(developer_positive_pct, 2),
      
      "<br>Total Reviews:",
      comma(total_reviews)
      
    )
    
  )
  
) +
  
  geom_boxplot(alpha = .7) +
  
  geom_jitter(
    width = .25,
    size = 2.5
  ) +
  
  theme_minimal() +
  
  labs(
    
    title =
      "Developer-Level Positive Review Sentiment",
    
    x = "",
    
    y =
      "Developer Positive Review Percentage"
    
  )

interactive_sentiment <-
  ggplotly(
    p1,
    tooltip = "text"
  )

interactive_sentiment

# ============================================================================
# Interactive Figure 2
# Review Volume
# ============================================================================

p2 <- ggplot(
  
  developer_summary,
  
  aes(
    
    x = category,
    
    y = total_reviews,
    
    fill = category,
    
    text = paste(
      
      "Developer:",
      developer,
      
      "<br>Total Reviews:",
      
      comma(total_reviews)
      
    )
    
  )
  
) +
  
  geom_boxplot(alpha = .7) +
  
  geom_jitter(
    width = .25,
    size = 2.5
  ) +
  
  scale_y_log10() +
  
  theme_minimal() +
  
  labs(
    
    title =
      "Developer-Level Review Volume",
    
    x = "",
    
    y =
      "Total Reviews (Log Scale)"
    
  )

interactive_reviews <-
  ggplotly(
    p2,
    tooltip = "text"
  )

interactive_reviews

# ============================================================================
# Interactive Figure 3
# Regression Plot
# ============================================================================

p3 <- ggplot(
  
  developer_summary,
  
  aes(
    
    x = log_reviews,
    
    y = developer_positive_pct,
    
    color = category,
    
    text = paste(
      
      "Developer:",
      developer,
      
      "<br>Category:",
      category,
      
      "<br>Positive %:",
      round(developer_positive_pct, 2),
      
      "<br>Total Reviews:",
      comma(total_reviews)
      
    )
    
  )
  
) +
  
  geom_point(
    size = 4
  ) +
  
  geom_smooth(
    method = "lm",
    se = TRUE
  ) +
  
  theme_minimal() +
  
  labs(
    
    title =
      "Developer-Level Review Sentiment vs Review Volume",
    
    x =
      "Log10(Total Reviews)",
    
    y =
      "Developer Positive Review Percentage"
    
  )

interactive_regression <-
  ggplotly(
    p3,
    tooltip = "text"
  )

interactive_regression

# ============================================================================
# Interactive Figure 4
# Developer Rankings
# ============================================================================

ranking_data <-
  
  developer_summary %>%
  
  arrange(
    desc(developer_positive_pct)
  ) %>%
  
  mutate(
    
    developer = factor(
      
      developer,
      
      levels =
        rev(developer)
      
    )
    
  )

p4 <- ggplot(
  
  ranking_data,
  
  aes(
    
    x = developer,
    
    y = developer_positive_pct,
    
    fill = category,
    
    text = paste(
      
      "Developer:",
      developer,
      
      "<br>Positive %:",
      round(developer_positive_pct, 2),
      
      "<br>Total Reviews:",
      comma(total_reviews)
      
    )
    
  )
  
) +
  
  geom_col() +
  
  coord_flip() +
  
  theme_minimal() +
  
  labs(
    
    title =
      "Developer Ranking by Average Review Sentiment",
    
    x = "",
    
    y =
      "Developer Positive Review Percentage"
    
  )

interactive_ranking <-
  ggplotly(
    p4,
    tooltip = "text"
  )

interactive_ranking

# ============================================================================
# Interactive Figure 5
# Bubble Chart
# ============================================================================

p5 <- ggplot(
  
  developer_summary,
  
  aes(
    
    x = developer_positive_pct,
    
    y = log_reviews,
    
    size = total_reviews,
    
    color = category,
    
    text = paste(
      
      "Developer:",
      developer,
      
      "<br>Average Positive %:",
      round(developer_positive_pct, 2),
      
      "<br>Total Reviews:",
      comma(total_reviews),
      
      "<br>Games Sampled:",
      games
      
    )
    
  )
  
) +
  
  geom_point(alpha = .8) +
  
  theme_minimal() +
  
  labs(
    
    title =
      "Developer Reputation Bubble Chart",
    
    x =
      "Developer Positive Review Percentage",
    
    y =
      "Log10(Total Reviews)"
    
  )

interactive_bubble <-
  ggplotly(
    p5,
    tooltip = "text"
  )

interactive_bubble

###############################################################################
# 11a. INTERACTIVE GAME-LEVEL DATA TABLE
###############################################################################

game_table <- review_data %>%
  
  mutate(
    
    negative_pct =
      (negative_reviews / total_reviews) * 100,
    
    review_volume =
      ifelse(
        low_review_volume,
        "Low Review Volume",
        "High Review Volume"
      )
    
  ) %>%
  
  select(
    
    game_name,
    developer,
    category,
    positive_reviews,
    negative_reviews,
    total_reviews,
    positive_pct,
    negative_pct,
    review_volume
    
  ) %>%
  
  arrange(
    desc(positive_pct)
  )

###############################################################################
# INTERACTIVE DATATABLE
###############################################################################

interactive_game_table <- datatable(
  
  game_table,
  
  extensions = "Buttons",
  
  rownames = FALSE,
  
  filter = "top",
  
  caption = htmltools::tags$caption(
    style = "caption-side: top; text-align: left;",
    "SteamSpy Game-Level Review Analysis"
  ),
  
  options = list(
    
    pageLength = 10,
    
    lengthMenu = c(5, 10, 20, 40),
    
    scrollX = TRUE,
    
    autoWidth = TRUE,
    
    dom = "Bfrtip",
    
    buttons = c(
      "copy",
      "csv",
      "excel"
    ),
    
    order = list(
      list(6, "desc")
    )
    
  )
  
) %>%
  
  formatRound(
    
    columns = c(
      "positive_pct",
      "negative_pct"
    ),
    
    digits = 2
    
  ) %>%
  
  formatCurrency(
    
    columns = c(
      "positive_reviews",
      "negative_reviews",
      "total_reviews"
    ),
    
    currency = "",
    
    digits = 0
    
  ) %>%
  
  
  formatStyle(
    
    "category",
    
    fontWeight = "bold"
    
  )

interactive_game_table
###############################################################################
# 12. EXPORT RESULTS
###############################################################################

write.csv(
  review_data,
  "steamspy_game_reviews.csv",
  row.names = FALSE
)

write.csv(
  developer_summary,
  "steamspy_developer_summary.csv",
  row.names = FALSE
)

write.csv(
  game_summary,
  "steamspy_category_summary.csv",
  row.names = FALSE
)

write.csv(
  regression_table,
  "steamspy_regression_results.csv",
  row.names = FALSE
)

# ============================================================================
# Save interactive figures
# ============================================================================

saveWidget(
  interactive_sentiment,
  "interactive_sentiment.html",
  selfcontained = TRUE
)

saveWidget(
  interactive_reviews,
  "interactive_review_volume.html",
  selfcontained = TRUE
)

saveWidget(
  interactive_regression,
  "interactive_regression.html",
  selfcontained = TRUE
)

saveWidget(
  interactive_ranking,
  "interactive_developer_ranking.html",
  selfcontained = TRUE
)

saveWidget(
  interactive_bubble,
  "interactive_bubble_chart.html",
  selfcontained = TRUE
)

saveWidget(
  
  interactive_game_table,
  
  "interactive_game_level_table.html",
  
  selfcontained = TRUE
  
)
###############################################################################
# END
###############################################################################