Business Problem

How can a video game music business use GEO to cause LLMs to mention their music?

Methodology and Analysis

We ran the following code to scrape YouTube and Steam comments, clean the data, run sentiment analysis, and compute a GEO Support Score that incorporated average sentiment, positive percent, and number of music comments.

install.packages(c(
  "tidyverse",
  "readxl",
  "openxlsx",
  "httr",
  "jsonlite",
  "stringr",
  "lubridate",
  "tidytext",
  "textdata"
))

############################################################
# MSBA 580 - Game Review / Soundtrack Sentiment Project
# Purpose:
# 1. Read game list from Excel
# 2. Pull YouTube comments when YouTube links exist
# 3. Pull Steam reviews when Steam links exist
# 4. Clean comments
# 5. Keep music-related comments
# 6. Run sentiment analysis
# 7. Export final Excel dataset
############################################################


############################################################
# 1. Load Packages
############################################################

library(tidyverse)
library(readxl)
library(openxlsx)
library(httr)
library(jsonlite)
library(stringr)
library(lubridate)
library(tidytext)


############################################################
# 2. Set File Names and API Key
############################################################

# Your input file
INPUT_FILE <- "JR_GameReview_updated_links.xlsx"

# Final output file
OUTPUT_FILE <- "JR_GameReview_Comments_Sentiment_RStudio.xlsx"

# Number of comments or reviews you want per game
COMMENTS_PER_GAME <- 50

# Paste your YouTube API key here
YOUTUBE_API_KEY <- "AIzaSyA_j__o3sE3wc3dYIQbWWA1i1WqfRcNOxc"


############################################################
# 3. Read the Game List
############################################################

games <- read_excel(INPUT_FILE)

# Remove extra blank columns if they exist
games <- games %>%
  select(where(~ !all(is.na(.x))))

# View column names
print(names(games))

# Check the first few rows
print(head(games))


############################################################
# 4. Helper Function: Extract YouTube Video ID
############################################################

extract_youtube_id <- function(url) {
  
  if (is.na(url) || url == "") {
    return(NA_character_)
  }
  
  url <- as.character(url)
  
  # Handles regular YouTube links like:
  # https://www.youtube.com/watch?v=VIDEOID
  if (str_detect(url, "v=")) {
    id <- str_match(url, "v=([^&]+)")[, 2]
    return(id)
  }
  
  # Handles short YouTube links like:
  # https://youtu.be/VIDEOID
  if (str_detect(url, "youtu.be/")) {
    id <- str_match(url, "youtu\\.be/([^?&]+)")[, 2]
    return(id)
  }
  
  # Handles embedded YouTube links
  if (str_detect(url, "embed/")) {
    id <- str_match(url, "embed/([^?&]+)")[, 2]
    return(id)
  }
  
  # Handles YouTube shorts
  if (str_detect(url, "shorts/")) {
    id <- str_match(url, "shorts/([^?&]+)")[, 2]
    return(id)
  }
  
  return(NA_character_)
}


############################################################
# 5. Helper Function: Pull YouTube Comments
############################################################

get_youtube_comments <- function(video_id, max_comments = 50) {
  
  all_comments <- tibble()
  next_page_token <- NULL
  
  while (nrow(all_comments) < max_comments) {
    
    api_url <- "https://www.googleapis.com/youtube/v3/commentThreads"
    
    query_list <- list(
      part = "snippet",
      videoId = video_id,
      key = YOUTUBE_API_KEY,
      maxResults = min(100, max_comments - nrow(all_comments)),
      textFormat = "plainText",
      order = "relevance"
    )
    
    if (!is.null(next_page_token)) {
      query_list$pageToken <- next_page_token
    }
    
    response <- GET(api_url, query = query_list)
    
    if (status_code(response) != 200) {
      message("YouTube API error for video ID: ", video_id)
      message(content(response, as = "text", encoding = "UTF-8"))
      break
    }
    
    data <- fromJSON(content(response, as = "text", encoding = "UTF-8"),
                     flatten = TRUE)
    
    if (!"items" %in% names(data)) {
      break
    }
    
    if (length(data$items) == 0) {
      break
    }
    
    comments_page <- as_tibble(data$items) %>%
      transmute(
        video_app_id = video_id,
        comment_text = snippet.topLevelComment.snippet.textDisplay,
        like_count = snippet.topLevelComment.snippet.likeCount,
        published_date = as.character(snippet.topLevelComment.snippet.publishedAt)
      )
    
    all_comments <- bind_rows(all_comments, comments_page)
    
    if ("nextPageToken" %in% names(data)) {
      next_page_token <- data$nextPageToken
    } else {
      break
    }
    
    Sys.sleep(0.25)
  }
  
  return(all_comments %>% slice_head(n = max_comments))
}


############################################################
# 6. Helper Function: Extract Steam App ID
############################################################

extract_steam_app_id <- function(url) {
  
  if (is.na(url) || url == "") {
    return(NA_character_)
  }
  
  url <- as.character(url)
  
  if (str_detect(url, "/app/")) {
    app_id <- str_match(url, "/app/(\\d+)")[, 2]
    return(app_id)
  }
  
  return(NA_character_)
}


############################################################
# 7. Helper Function: Pull Steam Reviews
############################################################

get_steam_reviews <- function(app_id, max_reviews = 50) {
  
  all_reviews <- tibble()
  cursor <- "*"
  
  while (nrow(all_reviews) < max_reviews) {
    
    api_url <- paste0("https://store.steampowered.com/appreviews/", app_id)
    
    query_list <- list(
      json = 1,
      filter = "recent",
      language = "english",
      review_type = "all",
      purchase_type = "all",
      num_per_page = min(100, max_reviews - nrow(all_reviews)),
      cursor = cursor
    )
    
    response <- GET(api_url, query = query_list)
    
    if (status_code(response) != 200) {
      message("Steam review error for app ID: ", app_id)
      break
    }
    
    data <- fromJSON(content(response, as = "text", encoding = "UTF-8"),
                     flatten = TRUE)
    
    if (!"reviews" %in% names(data)) {
      break
    }
    
    if (length(data$reviews) == 0) {
      break
    }
    
    reviews_page <- as_tibble(data$reviews) %>%
      transmute(
        video_app_id = app_id,
        comment_text = review,
        like_count = votes_up,
        published_date = as.character(as_datetime(timestamp_created)),
        steam_recommended = voted_up
      )
    
    all_reviews <- bind_rows(all_reviews, reviews_page)
    
    if (!is.null(data$cursor)) {
      new_cursor <- data$cursor
    } else {
      break
    }
    
    if (new_cursor == cursor) {
      break
    }
    
    cursor <- new_cursor
    
    Sys.sleep(0.25)
  }
  
  return(all_reviews %>% slice_head(n = max_reviews))
}


############################################################
# 8. Pull Comments / Reviews for Each Game
############################################################

comments_raw <- tibble()

for (i in 1:nrow(games)) {
  
  game_name <- games$`Game Name`[i]
  composer <- games$Composer[i]
  link <- games$`Soundtrack Research Link`[i]
  link_type <- games$`Link Type`[i]
  
  message("Working on: ", game_name)
  
  youtube_id <- extract_youtube_id(link)
  steam_id <- extract_steam_app_id(link)
  
  # Try YouTube first
  if (!is.na(youtube_id)) {
    
    temp_comments <- get_youtube_comments(
      video_id = youtube_id,
      max_comments = COMMENTS_PER_GAME
    )
    
    if (nrow(temp_comments) > 0) {
      temp_comments <- temp_comments %>%
        mutate(
          game_name = game_name,
          composer = composer,
          source = "YouTube",
          source_link = link,
          link_type = link_type,
          pulled_date = Sys.Date()
        )
      
      comments_raw <- bind_rows(comments_raw, temp_comments)
    }
  }
  
  # Use Steam if the link is a Steam link
  if (!is.na(steam_id)) {
    
    temp_reviews <- get_steam_reviews(
      app_id = steam_id,
      max_reviews = COMMENTS_PER_GAME
    )
    
    if (nrow(temp_reviews) > 0) {
      temp_reviews <- temp_reviews %>%
        mutate(
          game_name = game_name,
          composer = composer,
          source = "Steam",
          source_link = link,
          link_type = link_type,
          pulled_date = Sys.Date()
        )
      
      comments_raw <- bind_rows(comments_raw, temp_reviews)
    }
  }
  
  Sys.sleep(0.5)
}

# Add a comment ID
comments_raw <- comments_raw %>%
  mutate(comment_id = row_number()) %>%
  select(
    comment_id,
    game_name,
    composer,
    source,
    video_app_id,
    comment_text,
    like_count,
    published_date,
    source_link,
    link_type,
    pulled_date,
    everything()
  )


############################################################
# 9. Clean the Comments
############################################################

music_keywords <- c(
  "music",
  "soundtrack",
  "score",
  "ost",
  "theme",
  "song",
  "songs",
  "composer",
  "melody",
  "track",
  "tracks",
  "audio",
  "orchestra",
  "orchestral",
  "battle theme",
  "boss theme",
  "beautiful",
  "emotional",
  "atmosphere",
  "atmospheric",
  "sound",
  "listening",
  "listen",
  "piano",
  "violin",
  "vocals",
  "choir",
  "instrumental",
  "banger",
  "masterpiece"
)

clean_text <- function(text) {
  
  text <- if_else(is.na(text), "", as.character(text))
  text <- str_to_lower(text)
  text <- str_replace_all(text, "http\\S+|www\\S+", "")
  text <- str_replace_all(text, "[^a-zA-Z0-9\\s]", " ")
  text <- str_squish(text)
  
  return(text)
}

is_music_related <- function(text) {
  
  text <- str_to_lower(text)
  
  keyword_found <- any(str_detect(text, fixed(music_keywords)))
  
  ifelse(keyword_found, "Yes", "No")
}

comments_cleaned <- comments_raw %>%
  mutate(
    original_comment = comment_text,
    cleaned_comment = clean_text(comment_text),
    music_related = map_chr(cleaned_comment, is_music_related)
  ) %>%
  select(
    comment_id,
    game_name,
    composer,
    source,
    original_comment,
    cleaned_comment,
    music_related,
    like_count,
    published_date,
    source_link,
    pulled_date
  )


############################################################
# 10. Sentiment Analysis Using tidytext / Bing Lexicon
############################################################

# Keep only comments related to music
music_comments <- comments_cleaned %>%
  filter(music_related == "Yes")

# Break comments into individual words
comment_words <- music_comments %>%
  select(comment_id, game_name, composer, source, cleaned_comment) %>%
  unnest_tokens(word, cleaned_comment)

# Bing lexicon labels words as positive or negative
bing_sentiment <- get_sentiments("bing")

# Join comment words to sentiment words
comment_sentiment_words <- comment_words %>%
  inner_join(bing_sentiment, by = "word")

# Count positive and negative words per comment
sentiment_results <- comment_sentiment_words %>%
  count(comment_id, game_name, composer, source, sentiment) %>%
  pivot_wider(
    names_from = sentiment,
    values_from = n,
    values_fill = 0
  )

# Make sure positive and negative columns exist
if (!"positive" %in% names(sentiment_results)) {
  sentiment_results$positive <- 0
}

if (!"negative" %in% names(sentiment_results)) {
  sentiment_results$negative <- 0
}

# Calculate sentiment score
sentiment_results <- sentiment_results %>%
  mutate(
    sentiment_score = positive - negative,
    sentiment_label = case_when(
      sentiment_score > 0 ~ "Positive",
      sentiment_score < 0 ~ "Negative",
      TRUE ~ "Neutral"
    )
  )

# Add back comments that had no positive or negative sentiment words
sentiment_results <- music_comments %>%
  select(comment_id, game_name, composer, source, cleaned_comment, original_comment) %>%
  left_join(
    sentiment_results %>%
      select(comment_id, positive, negative, sentiment_score, sentiment_label),
    by = "comment_id"
  ) %>%
  mutate(
    positive = replace_na(positive, 0),
    negative = replace_na(negative, 0),
    sentiment_score = replace_na(sentiment_score, 0),
    sentiment_label = replace_na(sentiment_label, "Neutral")
  )


############################################################
# 11. Game-Level Summary
############################################################

game_sentiment_summary <- sentiment_results %>%
  group_by(game_name, composer) %>%
  summarise(
    total_music_comments = n(),
    positive_comments = sum(sentiment_label == "Positive"),
    neutral_comments = sum(sentiment_label == "Neutral"),
    negative_comments = sum(sentiment_label == "Negative"),
    avg_sentiment_score = mean(sentiment_score),
    positive_percent = positive_comments / total_music_comments,
    negative_percent = negative_comments / total_music_comments,
    .groups = "drop"
  ) %>%
  arrange(desc(avg_sentiment_score))


############################################################
# 12. Composer-Level Summary
############################################################

composer_sentiment_summary <- sentiment_results %>%
  group_by(composer) %>%
  summarise(
    total_music_comments = n(),
    positive_comments = sum(sentiment_label == "Positive"),
    neutral_comments = sum(sentiment_label == "Neutral"),
    negative_comments = sum(sentiment_label == "Negative"),
    avg_sentiment_score = mean(sentiment_score),
    positive_percent = positive_comments / total_music_comments,
    negative_percent = negative_comments / total_music_comments,
    .groups = "drop"
  ) %>%
  arrange(desc(avg_sentiment_score))


############################################################
# 13. Simple GEO Support Score
############################################################

# This is a simple project-friendly score.
# It combines:
# 1. Average sentiment
# 2. Positive percent
# 3. Number of music comments
#
# The score is not perfect, but it gives your group a clear,
# explainable number for presentation purposes.

geo_support_score <- game_sentiment_summary %>%
  mutate(
    sentiment_scaled = scales::rescale(avg_sentiment_score, to = c(0, 100)),
    positive_scaled = positive_percent * 100,
    comment_volume_scaled = scales::rescale(total_music_comments, to = c(0, 100)),
    geo_support_score = round(
      (sentiment_scaled * 0.40) +
        (positive_scaled * 0.40) +
        (comment_volume_scaled * 0.20),
      2
    )
  ) %>%
  arrange(desc(geo_support_score))


############################################################
# 14. Export Everything to Excel
############################################################

wb <- createWorkbook()

addWorksheet(wb, "Games")
writeData(wb, "Games", games)

addWorksheet(wb, "Comments_Raw")
writeData(wb, "Comments_Raw", comments_raw)

addWorksheet(wb, "Comments_Cleaned")
writeData(wb, "Comments_Cleaned", comments_cleaned)

addWorksheet(wb, "Sentiment_Results")
writeData(wb, "Sentiment_Results", sentiment_results)

addWorksheet(wb, "Game_Sentiment_Summary")
writeData(wb, "Game_Sentiment_Summary", game_sentiment_summary)

addWorksheet(wb, "Composer_Summary")
writeData(wb, "Composer_Summary", composer_sentiment_summary)

addWorksheet(wb, "GEO_Support_Score")
writeData(wb, "GEO_Support_Score", geo_support_score)

saveWorkbook(wb, OUTPUT_FILE, overwrite = TRUE)

message("Done! Your final file was created: ", OUTPUT_FILE)

Findings

What the sentiment results showed:

Stellar Blade had the strongest overall soundtrack sentiment Baldur’s Gate 3, Clair Obscur: Expedition 33, Half-Life: Alyx, and Omori also showed strong positive music reactions Hollow Knight and Deltarune had the most negative music-related comments The Witcher 3 and Astral Chain had very few usable music comments, so those results should be viewed carefully The GEO support score helped compare games by combining sentiment, positive percentage, and comment volume

Top GEO support score: Stellar Blade = 92.20

Visualizations

Application to GEO

How GEO Works

Comparison to SEO:

SEO: identify REPETITION (number of links, traffic, and times a keyword is mentioned)

GEO: optimize for PREDICTION (which linguistic patterns predict the linguistic patterns we want to make sure our business is mentioned in responses?)

GEO identifies patterns using the types of analyses we ran:

  • Co-occurrence modeling
  • High-information token associations (“haunting strings,” “lo-fi ambience”)
  • Statistical representation of a business that models descriptors that appear frequently, consistently, and across multiple sources

References

Amin, A. D. B. M., Toh, Z., Bhuiyan, M. I., Nafis, N. S., & Kamarudin, N. S. (2024). Sentiment analysis on YouTube comments using machine learning techniques based on video games content. arXiv. https://arxiv.org/abs/2511.06708

Dutta, S., Liu, D. & Samuel, A. (Oct. 5, 2025). Improving Consistency in Retrieval-Augmented Systems with Group Similarity Rewards. arXiv. https://arxiv.org/html/2510.04392v1

Optimizing your website for generative AI features on Google Search. (Jun 29, 2026). Google Search Central. https://developers.google.com/search/docs/fundamentals/ai-optimization-guide

Vaswani, A., Shazeer, N., Parmar, N.,…Kaiser, L. & Polosukhin I. (Jun. 12, 2017). Attention Is All You Need. Advances in neural information processing systems, 30. https://doi.org/10.48550/arXiv.1706.03762