library(qualtRics)
library(tidyverse)
library(psych)
library(knitr)
library(kableExtra)
library(ggcorrplot)
library(gridExtra)
library(scales)
library(interactions)
library(dplyr)   # re-attach after any MASS-loading packages

Key Takeaways

(Fill in after reviewing results)

Design: 2 (NPC Hostility: low vs. high) × 2 (NPC Capability: low vs. high), fully crossed.

Predictions: - Low hostile × Low capable: least aggression, most positive experience - High hostile × High capable: most aggression, most negative emotion - Low hostile × High capable and High hostile × Low capable: ambiguous cells where individual differences (SVO, Spite, Agreeableness) are expected to determine the course


1. Data Import & Cleaning

1.1 Load Data

raw <- read_survey("~/Google drive/My Drive/YEAR 3/PROJECTS/DANIEL/Competitive Jungle/CWV x Game/pilot6_data.csv")
cat("Raw N =", nrow(raw), "\n")
## Raw N = 440

1.2 Exclusions

# ── Manual exclusion IDs (add after reviewing open responses) ─────────────────
manual_exclusion_ids <- c(
  # "id1", "id2", ...
)

df_flags <- raw %>%
  mutate(
    flag_bot        = (Status != 0),
    flag_recaptcha  = (!is.na(Q_RecaptchaScore) & Q_RecaptchaScore < 0.5),
    flag_attn       = (!is.na(attn) & attn != 2),   # correct = "Somewhat disagree"
    flag_unfinished = (Finished != 1),
    flag_manual     = (participantId %in% manual_exclusion_ids),
    # Tech issues — flagged but NOT auto-excluded
    flag_tech_move  = (!is.na(move_player)  & move_player  == 2),
    flag_tech_other = (!is.na(other_player) & other_player == 2),
    flag_any_tech   = flag_tech_move | flag_tech_other
  )

flag_summary <- df_flags %>%
  summarise(
    N_raw        = n(),
    N_bot        = sum(flag_bot,        na.rm = TRUE),
    N_recaptcha  = sum(flag_recaptcha,  na.rm = TRUE),
    N_attn_fail  = sum(flag_attn,       na.rm = TRUE),
    N_unfinished = sum(flag_unfinished, na.rm = TRUE),
    N_manual     = sum(flag_manual,     na.rm = TRUE),
    N_tech_move  = sum(flag_tech_move,  na.rm = TRUE),
    N_tech_other = sum(flag_tech_other, na.rm = TRUE)
  )

kable(flag_summary,
      caption = "Exclusion flag counts (tech flags are informational only)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Exclusion flag counts (tech flags are informational only)
N_raw N_bot N_recaptcha N_attn_fail N_unfinished N_manual N_tech_move N_tech_other
440 0 10 3 40 0 30 1
df <- df_flags %>%
  filter(!flag_bot, !flag_recaptcha, !flag_attn,
         !flag_unfinished, !flag_manual)

cat("N after exclusions =", nrow(df), "\n")
## N after exclusions = 388
cat("N flagged for tech issues (still in data) =",
    sum(df$flag_any_tech, na.rm = TRUE), "\n")
## N flagged for tech issues (still in data) = 28

1.3 Technical Issue Cases

df %>%
  filter(flag_any_tech) %>%
  dplyr::select(participantId, move_player, other_player, npc_hostile,
                npc_capable, open, feedback, Player_score, Player_shake_count) %>%
  kable(caption = "Flagged tech issue participants — review before deciding exclusion") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE)
Flagged tech issue participants — review before deciding exclusion
participantId move_player other_player npc_hostile npc_capable open feedback Player_score Player_shake_count
B2A2ADCC8DA641E2B7F2162532EB402A 2 1 low low I thought it was fun, interesting, and different. I was engaged. NA 10 42
41EB3FB16BE84CE1A09B55B775F34B14 2 1 low low moving to the tree to shake it and collect fruit which was then taken to my hut moving to the opponents hut and taking fruit from their hut and moving it to my hut very entertaining game |none
# Flag participants with no game data at all (player_score is blank)
df <- df %>%
  mutate(flag_no_game_data = is.na(as.numeric(Player_score)))

cat("N flagged for missing all game data:", sum(df$flag_no_game_data, na.rm = TRUE), "\n")
## N flagged for missing all game data: 5
# Print for review
df %>%
  filter(flag_no_game_data) %>%
  dplyr::select(participantId, Player_score,
                move_player, other_player, open, feedback) %>%
  kable(caption = "Participants with no game data (Player_score is blank)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE)
Participants with no game data (Player_score is blank)
participantId Player_score move_player other_player open feedback
945B815AA9D04EDBA58B91E4C01FA46D NA 2 1 The game was really cute. I just was sad cause I couldn’t move my player and participate. Yes, I had problems with controlling my player. Wasn’t functioning properly. I could only stun.
81324513D40F44578F9A5F72F643F9A6 NA 1 1 just had to get used to playing it. no other problems occurred. NA
BF461FDE9A604013A57B3DE165081973 NA 2 1 I did not enjoy the game because I was not able to move my player. This was frustrating. NA
A22E345F2A2548F7A35456945E8A45A9 NA 1 1 It was easy and fun to shake the tree for fruit and then bring it back to the hut for points. NA
8CDF89042C35400FA532A5A38B4F90D3 NA 1 1 Playing the game was engaging and fun and slightly difficult to pick the apple. NA

1.3b Post-Review Exclusions

# Exclude participants who said they could move but have zero score AND zero shakes
# Exclude participants who said they could not move and got zero on all behavioral outcomes
# Exclude participants with no game data at all (Player_score is blank)

pre_n <- nrow(df)

df <- df %>%
  filter(
    # Unreported tech issue: claimed no problem but no evidence of play
    !(move_player == 1 &
        as.numeric(Player_score) == 0 &
        as.numeric(Player_shake_count) == 0),
    # Confirmed tech issue with zero behavioral data
    !(move_player == 2 &
        as.numeric(Player_score) == 0 &
        as.numeric(Player_shake_count) == 0 &
        as.numeric(Player_stuns_Attacker) == 0 &
        as.numeric(Player_steals_Attacker) == 0 &
        as.numeric(Player_raids_hut) == 0),
    # No game data at all
    !is.na(as.numeric(Player_score))
  )

cat("N removed in post-review:", pre_n - nrow(df), "\n")
## N removed in post-review: 18
cat("Final N =", nrow(df), "\n")
## Final N = 370

1.4 Scoring & Variable Creation

# ── SVO Slider Scoring ────────────────────────────────────────────────────────
# Items recoded in Qualtrics to positions 1-9
svo_payoffs <- list(
  item1 = tibble(pos = 1:9,
                 you   = c(85,85,85,85,85,85,85,85,85),
                 other = c(85,76,68,59,50,41,33,24,15)),
  item2 = tibble(pos = 1:9,
                 you   = c(85,87,89,91,93,94,96,98,100),
                 other = c(15,19,24,28,33,37,41,46,50)),
  item3 = tibble(pos = 1:9,
                 you   = c(50,54,59,63,68,72,76,81,85),
                 other = c(100,98,96,94,93,91,89,87,85)),
  item4 = tibble(pos = 1:9,
                 you   = c(50,54,59,63,68,72,76,81,85),
                 other = c(100,89,79,68,58,47,36,26,15)),
  item5 = tibble(pos = 1:9,
                 you   = c(100,94,88,81,75,69,63,56,50),
                 other = c(50,56,63,69,75,81,88,94,100)),
  item6 = tibble(pos = 1:9,
                 you   = c(100,98,96,94,93,91,89,87,85),
                 other = c(50,54,59,63,68,72,76,81,85))
)

get_payoff <- function(pos, item_num, type) {
  pos <- as.integer(pos)
  payoffs <- svo_payoffs[[paste0("item", item_num)]]
  row <- payoffs[payoffs$pos == pos, ]
  if (nrow(row) == 0) return(NA_real_)
  row[[type]]
}

df <- df %>%
  rowwise() %>%
  mutate(
    svo1_you   = get_payoff(SVO_1, 1, "you"),
    svo1_other = get_payoff(SVO_1, 1, "other"),
    svo2_you   = get_payoff(SVO_2, 2, "you"),
    svo2_other = get_payoff(SVO_2, 2, "other"),
    svo3_you   = get_payoff(SVO_3, 3, "you"),
    svo3_other = get_payoff(SVO_3, 3, "other"),
    svo4_you   = get_payoff(SVO_4, 4, "you"),
    svo4_other = get_payoff(SVO_4, 4, "other"),
    svo5_you   = get_payoff(SVO_5, 5, "you"),
    svo5_other = get_payoff(SVO_5, 5, "other"),
    svo6_you   = get_payoff(SVO_6, 6, "you"),
    svo6_other = get_payoff(SVO_6, 6, "other"),
    svo_mean_you   = mean(c(svo1_you,   svo2_you,   svo3_you,
                             svo4_you,   svo5_you,   svo6_you),   na.rm = TRUE),
    svo_mean_other = mean(c(svo1_other, svo2_other, svo3_other,
                             svo4_other, svo5_other, svo6_other), na.rm = TRUE),
    svo_angle = atan((svo_mean_other - 50) / (svo_mean_you - 50)) * (180 / pi)
  ) %>%
  ungroup() %>%
  mutate(
    svo_type = factor(case_when(
      svo_angle >  57.15 ~ "Altruistic",
      svo_angle >  22.45 ~ "Prosocial",
      svo_angle > -12.04 ~ "Individualistic",
      !is.na(svo_angle)  ~ "Competitive",
      TRUE               ~ NA_character_
    ), levels = c("Competitive","Individualistic","Prosocial","Altruistic")),

    # ── Spite (1-7, recoded in Qualtrics) ─────────────────────────────────────
    spite = rowMeans(cbind(as.numeric(spite_1), as.numeric(spite_2),
                            as.numeric(spite_3), as.numeric(spite_4)),
                     na.rm = TRUE),

    # ── TIPI ──────────────────────────────────────────────────────────────────
    tipi_extra_6r  = 8 - as.numeric(Extraversion_6R),
    tipi_agree_2r  = 8 - as.numeric(Agreeable_2R),
    tipi_consc_8r  = 8 - as.numeric(Conscientious_8R),
    tipi_emosta_4r = 8 - as.numeric(EmoStability_4R),
    tipi_open_10r  = 8 - as.numeric(Open_10R),
    tipi_extraversion      = (as.numeric(Extraversion_1)   + tipi_extra_6r)  / 2,
    tipi_agreeableness     = (as.numeric(Agreeable_7)      + tipi_agree_2r)  / 2,
    tipi_conscientiousness = (as.numeric(Conscientious_3)  + tipi_consc_8r)  / 2,
    tipi_emo_stability     = (as.numeric(EmoStability_9)   + tipi_emosta_4r) / 2,
    tipi_openness          = (as.numeric(Open_5)           + tipi_open_10r)  / 2,

    # ── Opponent perceptions ──────────────────────────────────────────────────
    opp_capability = rowMeans(cbind(as.numeric(opp_perc_comp_1),
                                     as.numeric(opp_perc_comp_2),
                                     as.numeric(opp_perc_comp_3),
                                     as.numeric(opp_perc_comp_4)),
                               na.rm = TRUE),

    # ── Moral outrage composite ───────────────────────────────────────────────
    moral_outrage = rowMeans(cbind(as.numeric(outrage_1),
                                    as.numeric(outrage_2),
                                    as.numeric(outrage_3)),
                              na.rm = TRUE),

    # ── Behavioral composites ─────────────────────────────────────────────────────
    noninstr_aggression = as.numeric(Player_stuns_Attacker),
    instr_aggression    = as.numeric(Player_steals_Attacker) +
                      as.numeric(Player_raids_hut),
    total_player_agg    = noninstr_aggression + instr_aggression,
    shake_count         = as.numeric(Player_shake_count),
    player_score        = as.numeric(Player_score),
    enemy_score         = as.numeric(Enemy_score),
    score_diff          = player_score - enemy_score,

    # ── Subjective intent ─────────────────────────────────────────────────────
    intent_steal_n   = as.numeric(intent_steal),
    intent_stun_n    = as.numeric(intent_stun),
    intent_produce_n = as.numeric(intent_produce),
    intent_raid_n = as.numeric(intent_raid),
    subj_intent_1    = as.numeric(subj_intent_1),
    subj_intent_2    = as.numeric(subj_intent_2),
    subj_intent_3    = as.numeric(subj_intent_3),

    # ── Opponent behavior perceptions ─────────────────────────────────────────
    op_intent_steal_n   = as.numeric(op_intent_steal),
    op_intent_stun_n    = as.numeric(op_intent_stun),
    op_intent_produce_n = as.numeric(op_intent_produce),
    op_intent_raid_n = as.numeric(op_intent_raid),
    opp_subj_intent_1   = as.numeric(opp_subj_intent_1),
    opp_subj_intent_2   = as.numeric(opp_ubj_intent_2),   # note: typo in Qualtrics
    opp_subj_intent_3   = as.numeric(opp_subj_intent_3),
    opp_agg_intent      = rowMeans(cbind(op_intent_steal_n, op_intent_stun_n, op_intent_raid_n),
                                    na.rm = TRUE),

    # ── Emotions & experience ─────────────────────────────────────────────────
    positive_n    = as.numeric(positive),
    play_again_n  = as.numeric(play_again),
    challenged_n  = as.numeric(challenged),
    threatened_n  = as.numeric(threatened),
    bored_n       = as.numeric(bored),
    engaged_n     = as.numeric(engaged),
    angry_n       = as.numeric(angry),
    frustrated_n  = as.numeric(frustrated),
    sad_n         = as.numeric(sad),
    guilty_n      = as.numeric(guilty),
    happy_n       = as.numeric(happy),
    difficulty_n  = as.numeric(difficulty),

    # ── Condition factors ─────────────────────────────────────────────────────
    hostile  = factor(npc_hostile,  levels = c("low","high")),
    capable  = factor(npc_capable,  levels = c("low","high")),
    hostile_n = if_else(npc_hostile == "high", 1L, 0L),
    capable_n = if_else(npc_capable == "high", 1L, 0L),
    cond_4    = interaction(hostile, capable, sep = " × "),
    cond_4    = factor(cond_4, levels = c("low × low","low × high",
                                           "high × low","high × high")),

    # ── Centered individual differences ───────────────────────────────────────
    svo_c    = as.numeric(scale(svo_angle)),
    spite_c  = as.numeric(scale(spite)),
    agree_c  = as.numeric(scale(tipi_agreeableness)),

    # ── Demographics ──────────────────────────────────────────────────────────
    gender_label = case_when(
      gender == 1 ~ "Male", gender == 2 ~ "Female",
      gender == 3 ~ "Non-binary", TRUE ~ "Other/NR"
    ),
    game_freq_label = factor(case_when(
      game_frequency == 1 ~ "Never",
      game_frequency == 2 ~ "< Once/month",
      game_frequency == 3 ~ "Few times/month",
      game_frequency == 4 ~ "Few times/week",
      game_frequency == 5 ~ "Daily/almost daily",
      TRUE ~ NA_character_
    ), levels = c("Never","< Once/month","Few times/month",
                  "Few times/week","Daily/almost daily"))
  )

2. Sample Overview

2.1 Demographics

df %>%
  summarise(
    N          = n(),
    Age_M      = round(mean(as.numeric(age), na.rm = TRUE), 1),
    Age_SD     = round(sd(as.numeric(age),   na.rm = TRUE), 1),
    Pct_Female = paste0(round(mean(gender == 2, na.rm = TRUE) * 100, 1), "%"),
    Pct_Male   = paste0(round(mean(gender == 1, na.rm = TRUE) * 100, 1), "%")
  ) %>%
  kable(caption = "Sample demographics") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Sample demographics
N Age_M Age_SD Pct_Female Pct_Male
370 41.4 12.6 53% 45.4%

2.2 Condition Balance

df %>%
  count(hostile, capable) %>%
  mutate(cond = paste0("Hostile=", hostile, ", Capable=", capable)) %>%
  dplyr::select(cond, n) %>%
  kable(col.names = c("Condition", "N"),
        caption = "Cell sizes in 2×2 design") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Cell sizes in 2×2 design
Condition N
Hostile=low, Capable=low 98
Hostile=low, Capable=high 93
Hostile=high, Capable=low 88
Hostile=high, Capable=high 91
# Check individual differences are balanced across conditions
id_balance <- df %>%
  group_by(hostile, capable) %>%
  summarise(SVO_M    = round(mean(svo_angle, na.rm = TRUE), 2),
            Spite_M  = round(mean(spite,     na.rm = TRUE), 2),
            Agree_M  = round(mean(tipi_agreeableness, na.rm = TRUE), 2),
            .groups  = "drop")

kable(id_balance,
      caption = "Individual difference means by condition (balance check)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Individual difference means by condition (balance check)
hostile capable SVO_M Spite_M Agree_M
low low 25.07 2.42 5.23
low high 25.77 2.37 5.32
high low 27.43 2.19 5.32
high high 27.87 2.27 5.49
# ANOVA balance check: are individual differences randomly distributed across conditions?
id_balance_tests <- map_dfr(
  list(SVO          = "svo_angle",
       Spite        = "spite",
       Agreeableness = "tipi_agreeableness",
       Extraversion  = "tipi_extraversion",
       Conscientiousness = "tipi_conscientiousness",
       Emo_Stability = "tipi_emo_stability",
       Openness      = "tipi_openness"),
  function(var) {
    m <- aov(as.formula(paste(var, "~ hostile * capable")), data = df)
    s <- summary(m)[[1]]
    tibble(
      `Hostile F`   = round(s$`F value`[1], 3),
      `Hostile p`   = round(s$`Pr(>F)`[1],  3),
      `Capable F`   = round(s$`F value`[2], 3),
      `Capable p`   = round(s$`Pr(>F)`[2],  3),
      `Interact. F` = round(s$`F value`[3], 3),
      `Interact. p` = round(s$`Pr(>F)`[3],  3)
    )
  }, .id = "Individual Difference"
)

kable(id_balance_tests,
      caption = "Balance check: individual differences across conditions (should all be non-significant)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Balance check: individual differences across conditions (should all be non-significant)
Individual Difference Hostile F Hostile p Capable F Capable p Interact. F Interact. p
SVO 2.365 0.125 0.153 0.696 0.008 0.930
Spite 1.352 0.246 0.011 0.916 0.171 0.679
Agreeableness 1.027 0.312 0.953 0.330 0.101 0.751
Extraversion 0.987 0.321 3.664 0.056 0.317 0.574
Conscientiousness 0.020 0.889 0.499 0.480 0.019 0.891
Emo_Stability 4.044 0.045 3.534 0.061 0.349 0.555
Openness 0.024 0.876 0.739 0.391 0.032 0.858

Randomization was largely successful — individual differences were balanced across conditions, with one exception: emotional stability was marginally higher in the low hostile condition (p = .045), which should prob be noted as a limitation.


3. Scale Reliability & Descriptives

3.1 Cronbach’s Alpha

alphas <- list(
  "Spite (4-item)"              = df %>% dplyr::select(spite_1, spite_2,
                                                         spite_3, spite_4),
  "Opp. Capability Perception"  = df %>% dplyr::select(opp_perc_comp_1,
                                                         opp_perc_comp_2,
                                                         opp_perc_comp_3,
                                                         opp_perc_comp_4),
  "Moral Outrage"               = df %>% dplyr::select(outrage_1, outrage_2,
                                                         outrage_3)
)

map_dfr(alphas, function(items) {
  a <- psych::alpha(items %>% mutate(across(everything(), as.numeric)),
                    warnings = FALSE)
  tibble(alpha   = round(a$total$raw_alpha, 3),
         n_items = ncol(items))
}, .id = "Scale") %>%
  kable(col.names = c("Scale","Cronbach's α","N Items"),
        caption = "Internal consistency") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Internal consistency
Scale Cronbach’s α N Items
Spite (4-item) 0.875 4
Opp. Capability Perception 0.956 4
Moral Outrage 0.910 3

3.2 Individual Difference Descriptives & SVO Distribution

df %>%
  dplyr::select(svo_angle, spite, tipi_agreeableness,
                tipi_extraversion, tipi_conscientiousness,
                tipi_emo_stability, tipi_openness) %>%
  pivot_longer(everything(), names_to = "Variable", values_to = "Value") %>%
  group_by(Variable) %>%
  summarise(M   = round(mean(as.numeric(Value), na.rm = TRUE), 2),
            SD  = round(sd(as.numeric(Value),   na.rm = TRUE), 2),
            Min = round(min(as.numeric(Value),  na.rm = TRUE), 2),
            Max = round(max(as.numeric(Value),  na.rm = TRUE), 2),
            .groups = "drop") %>%
  kable(caption = "Individual difference descriptives") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Individual difference descriptives
Variable M SD Min Max
spite 2.31 1.37 1.00 6.50
svo_angle 26.50 14.01 -16.26 53.88
tipi_agreeableness 5.34 1.26 1.00 7.00
tipi_conscientiousness 5.38 1.30 1.00 7.00
tipi_emo_stability 4.85 1.49 1.00 7.00
tipi_extraversion 3.40 1.66 1.00 7.00
tipi_openness 5.22 1.27 1.00 7.00
# SVO distribution
df %>%
  filter(!is.na(svo_type)) %>%
  count(svo_type) %>%
  mutate(pct = n / sum(n)) %>%
  ggplot(aes(x = svo_type, y = pct, fill = svo_type)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = paste0(n, "\n(", percent(pct, 1), ")")),
            vjust = -0.3, size = 3.5) +
  scale_y_continuous(labels = percent_format(), limits = c(0, 0.8)) +
  scale_fill_manual(values = c("Competitive"     = "#E07B54",
                                "Individualistic" = "#F0C274",
                                "Prosocial"       = "#6BAE75",
                                "Altruistic"      = "#5B8DB8")) +
  labs(title = "SVO Type Distribution", x = NULL, y = "Proportion") +
  theme_minimal()

No altruistic people!


4. Manipulation Checks

4.1 Perceived Difficulty by Condition

aov_diff <- aov(difficulty_n ~ hostile * capable, data = df)
summary(aov_diff)
##                  Df Sum Sq Mean Sq F value   Pr(>F)    
## hostile           1  183.8  183.80  59.176 1.35e-13 ***
## capable           1   94.9   94.91  30.556 6.18e-08 ***
## hostile:capable   1   23.2   23.16   7.457  0.00662 ** 
## Residuals       366 1136.8    3.11                     
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
df %>%
  group_by(hostile, capable) %>%
  summarise(M  = round(mean(difficulty_n, na.rm = TRUE), 2),
            SE = round(sd(difficulty_n,   na.rm = TRUE) / sqrt(n()), 2),
            .groups = "drop") %>%
  ggplot(aes(x = capable, y = M, fill = hostile)) +
  geom_col(position = position_dodge(0.6), width = 0.5) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE),
                position = position_dodge(0.6), width = 0.2) +
  scale_fill_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                    name = "NPC Hostile") +
  scale_y_continuous(limits = c(0, 7)) +
  labs(title = "Perceived Difficulty by Condition",
       x = "NPC Capability", y = "Mean Difficulty (1–7)") +
  theme_minimal()

I think pretty much what we would expect!

4.2 Perceived Opponent Capability

aov_cap <- aov(opp_capability ~ hostile * capable, data = df)
summary(aov_cap)
##                  Df Sum Sq Mean Sq F value   Pr(>F)    
## hostile           1   55.4   55.36  17.498 3.60e-05 ***
## capable           1   79.4   79.40  25.095 8.51e-07 ***
## hostile:capable   1    5.1    5.12   1.619    0.204    
## Residuals       366 1158.0    3.16                     
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
df %>%
  group_by(hostile, capable) %>%
  summarise(M  = round(mean(opp_capability, na.rm = TRUE), 2),
            SE = round(sd(opp_capability,   na.rm = TRUE) / sqrt(n()), 2),
            .groups = "drop") %>%
  ggplot(aes(x = capable, y = M, fill = hostile)) +
  geom_col(position = position_dodge(0.6), width = 0.5) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE),
                position = position_dodge(0.6), width = 0.2) +
  scale_fill_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                    name = "NPC Hostile") +
  scale_y_continuous(limits = c(0, 7)) +
  labs(title = "Perceived Opponent Capability by Condition",
       x = "NPC Capability", y = "Mean Perceived Capability (1–7)") +
  theme_minimal()

The capability manipulation is working as intended, but hostility independently inflates perceived capability, suggesting participants conflate aggressive behavior with competence.

4.3 Perceived Opponent Hostility

aov_host <- aov(opp_agg_intent ~ hostile * capable, data = df)
summary(aov_host)
##                  Df Sum Sq Mean Sq F value   Pr(>F)    
## hostile           1  725.6   725.6  578.43  < 2e-16 ***
## capable           1   41.4    41.4   32.98 1.96e-08 ***
## hostile:capable   1   34.1    34.1   27.16 3.14e-07 ***
## Residuals       366  459.1     1.3                     
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
df %>%
  group_by(hostile, capable) %>%
  summarise(M  = round(mean(opp_agg_intent, na.rm = TRUE), 2),
            SE = round(sd(opp_agg_intent,   na.rm = TRUE) / sqrt(n()), 2),
            .groups = "drop") %>%
  ggplot(aes(x = hostile, y = M, fill = capable)) +
  geom_col(position = position_dodge(0.6), width = 0.5) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE),
                position = position_dodge(0.6), width = 0.2) +
  scale_fill_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                    name = "NPC Capable") +
  scale_y_continuous(limits = c(0, 7)) +
  labs(title = "Perceived Opponent Aggressive Intent by Condition",
       x = "NPC Hostility", y = "Mean Perceived Aggressive Intent (1–7)") +
  theme_minimal()

opp_agg_intent is the mean of op_intent_steal, op_intent_stun, and op_intent_raid.

4.4 Attacker Behavioral Counts by Condition

Objective check that the NPC behaved as intended.

df %>%
  mutate(across(c(Attacker_stuns_Player, Attacker_steals_Player,
                   Attacker_raids_hut, Attacker_shake_count,
                   Enemy_score), as.numeric)) %>%
  group_by(hostile, capable) %>%
  summarise(
    Stuns_M   = round(mean(Attacker_stuns_Player,  na.rm = TRUE), 2),
    Steals_M  = round(mean(Attacker_steals_Player, na.rm = TRUE), 2),
    Raids_M   = round(mean(Attacker_raids_hut,     na.rm = TRUE), 2),
    Shakes_M  = round(mean(Attacker_shake_count,   na.rm = TRUE), 2),
    Score_M   = round(mean(Enemy_score,            na.rm = TRUE), 2),
    .groups   = "drop"
  ) %>%
  kable(caption = "Attacker behavior by condition (objective manipulation check)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Attacker behavior by condition (objective manipulation check)
hostile capable Stuns_M Steals_M Raids_M Shakes_M Score_M
low low 0.00 0.00 0.00 91.41 6.28
low high 0.00 0.00 0.00 110.58 9.47
high low 5.58 2.92 0.94 35.74 3.84
high high 8.78 5.60 1.59 38.15 5.74

5. Behavioral Outcomes Descriptives

5.1 Player Behavior by Condition

beh_summary <- df %>%
  group_by(hostile, capable) %>%
  summarise(
    Score_M    = round(mean(player_score,        na.rm = TRUE), 2),
    Stuns_M    = round(mean(noninstr_aggression, na.rm = TRUE), 2),
    InstrAgg_M = round(mean(instr_aggression,    na.rm = TRUE), 2),
    Shakes_M   = round(mean(shake_count,         na.rm = TRUE), 2),
    TotalAgg_M = round(mean(total_player_agg,    na.rm = TRUE), 2),
    .groups    = "drop"
  )

kable(beh_summary,
      col.names = c("Hostile","Capable","Score","Stuns",
                    "Instr. Agg.","Shakes","Total Agg."),
      caption = "Player behavioral outcomes by condition (means)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Player behavioral outcomes by condition (means)
Hostile Capable Score Stuns Instr. Agg. Shakes Total Agg.
low low 12.70 1.41 4.59 43.19 6.00
low high 12.82 1.83 4.81 45.83 6.63
high low 6.58 8.01 4.14 39.86 12.15
high high 4.05 9.99 6.63 30.47 16.62
# Figure: total aggression by condition
df %>%
  group_by(cond_4) %>%
  summarise(M  = mean(total_player_agg, na.rm = TRUE),
            SE = sd(total_player_agg,   na.rm = TRUE) / sqrt(n()),
            .groups = "drop") %>%
  ggplot(aes(x = cond_4, y = M, fill = cond_4)) +
  geom_col(show.legend = FALSE, width = 0.6) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE), width = 0.2) +
  scale_fill_manual(values = c("low × low"   = "#5B8DB8",
                                "low × high"  = "#A8C8E0",
                                "high × low"  = "#F0C274",
                                "high × high" = "#E07B54")) +
  labs(title = "Total Aggression by Condition",
       subtitle = "Hostile × Capable",
       x = "Condition (Hostile × Capable)", y = "Mean Total Aggression") +
  theme_minimal()

Looks like hostility is the primary trigger for player aggression and capability amplifies it. Players retaliate most against opponents who are both hostile and capable, exactly as predicted. Woo!


6. Full Correlation Matrix

cor_df <- df %>%
  dplyr::select(
    # Objective behavioral outcomes
    player_score, noninstr_aggression, instr_aggression,
    Player_steals_Attacker, Player_raids_hut, shake_count, score_diff,
    # Self-reported intent
    intent_steal_n, intent_stun_n, intent_produce_n, intent_raid_n,
    subj_intent_1, subj_intent_2, subj_intent_3,
    # Opponent perceptions
    opp_capability, op_intent_steal_n, op_intent_stun_n, op_intent_raid_n,
    op_intent_produce_n, opp_subj_intent_1, opp_subj_intent_2, opp_subj_intent_3,
    # Emotions & experience
    positive_n, play_again_n, moral_outrage,
    challenged_n, threatened_n, bored_n, engaged_n,
    angry_n, frustrated_n, sad_n, guilty_n, happy_n,
    # Individual differences
    svo_angle, spite, tipi_agreeableness, tipi_conscientiousness,
    tipi_extraversion, tipi_emo_stability, tipi_openness,
    # Condition (numeric)
    hostile_n, capable_n,
    # Gaming
    game_frequency, skill_level
  ) %>%
  mutate(across(everything(), as.numeric))

names(cor_df) <- c(
  "Score","Stuns","Instr. Agg.","Steals","Raids","Shakes","Score Diff",
  "Intent Steal","Intent Stun","Intent Produce","Intent Raid",
  "Subj: Indiv.","Subj: Compet.","Subj: Prosoc.",
  "Opp. Capability","Opp Intent Steal","Opp Intent Stun","Opp Intent Raid",
  "Opp Intent Produce","Opp: Indiv.","Opp: Compet.","Opp: Prosoc.",
  "Positive","Play Again","Moral Outrage",
  "Challenged","Threatened","Bored","Engaged",
  "Angry","Frustrated","Sad","Guilty","Happy",
  "SVO","Spite","Agreeableness","Conscientiousness",
  "Extraversion","Emo. Stability","Openness",
  "NPC Hostile","NPC Capable",
  "Game Freq.","Skill Level"
)

cor_mat <- cor(cor_df, use = "pairwise.complete.obs")

cor_mat %>%
  round(2) %>%
  kable(caption = "Full correlation matrix") %>%
  kable_styling(bootstrap_options = c("striped","condensed"),
                font_size = 8, full_width = TRUE) %>%
  scroll_box(width = "100%", height = "500px")
Full correlation matrix
Score Stuns Instr. Agg. Steals Raids Shakes Score Diff Intent Steal Intent Stun Intent Produce Intent Raid Subj: Indiv. Subj: Compet. Subj: Prosoc. Opp. Capability Opp Intent Steal Opp Intent Stun Opp Intent Raid Opp Intent Produce Opp: Indiv. Opp: Compet. Opp: Prosoc. Positive Play Again Moral Outrage Challenged Threatened Bored Engaged Angry Frustrated Sad Guilty Happy SVO Spite Agreeableness Conscientiousness Extraversion Emo. Stability Openness NPC Hostile NPC Capable Game Freq. Skill Level
Score 1.00 -0.47 0.06 0.19 -0.18 0.41 0.81 0.17 -0.12 0.08 -0.27 -0.03 0.26 0.04 -0.53 -0.62 -0.62 -0.62 0.31 0.31 -0.49 0.38 0.32 0.10 -0.40 -0.51 -0.28 0.14 -0.02 -0.37 -0.56 -0.18 0.09 0.22 -0.09 0.05 -0.17 -0.05 -0.02 -0.13 -0.04 -0.71 -0.13 0.10 0.18
Stuns -0.47 1.00 0.08 -0.12 0.30 -0.27 -0.11 0.10 0.39 -0.13 0.29 -0.15 -0.07 -0.20 -0.04 0.31 0.45 0.49 -0.36 -0.29 0.03 -0.26 -0.16 -0.12 0.30 0.06 0.31 0.06 -0.06 0.26 0.24 0.11 0.01 -0.14 -0.01 -0.01 -0.07 -0.05 -0.04 0.04 -0.02 0.58 0.10 0.07 0.12
Instr. Agg. 0.06 0.08 1.00 0.79 0.51 -0.38 0.34 0.48 0.41 -0.39 0.35 -0.28 0.14 -0.32 -0.37 0.08 0.12 0.04 -0.04 0.02 -0.10 -0.08 0.01 -0.04 0.02 -0.22 -0.06 0.13 -0.13 0.01 -0.10 -0.02 0.02 0.00 -0.20 0.09 -0.18 -0.08 -0.02 0.00 0.00 0.07 0.14 0.10 0.25
Steals 0.19 -0.12 0.79 1.00 -0.12 -0.17 0.33 0.46 0.27 -0.23 -0.07 -0.10 0.05 -0.18 -0.37 0.01 -0.03 -0.10 -0.01 0.12 -0.12 -0.04 -0.01 -0.09 -0.05 -0.26 -0.13 0.17 -0.17 -0.06 -0.13 -0.01 0.00 0.00 -0.18 0.12 -0.16 0.00 -0.02 0.03 0.03 -0.09 0.01 0.05 0.18
Raids -0.18 0.30 0.51 -0.12 1.00 -0.38 0.09 0.14 0.28 -0.32 0.67 -0.32 0.16 -0.28 -0.09 0.13 0.24 0.21 -0.06 -0.14 0.02 -0.08 0.02 0.07 0.11 0.01 0.08 -0.02 0.02 0.11 0.03 -0.02 0.03 0.01 -0.08 -0.01 -0.07 -0.12 -0.01 -0.05 -0.04 0.24 0.21 0.08 0.15
Shakes 0.41 -0.27 -0.38 -0.17 -0.38 1.00 0.11 -0.32 -0.33 0.57 -0.48 0.24 0.02 0.29 0.03 -0.14 -0.15 -0.19 -0.02 -0.04 -0.15 0.12 0.09 0.00 -0.15 -0.14 0.00 0.05 -0.02 -0.07 -0.16 -0.01 0.00 -0.03 0.14 -0.08 0.00 -0.08 -0.05 -0.11 -0.02 -0.17 -0.06 0.05 -0.02
Score Diff 0.81 -0.11 0.34 0.33 0.09 0.11 1.00 0.35 0.14 -0.11 0.03 -0.23 0.35 -0.15 -0.72 -0.48 -0.42 -0.38 0.10 0.13 -0.60 0.27 0.29 0.05 -0.34 -0.58 -0.23 0.16 -0.06 -0.32 -0.55 -0.20 0.07 0.17 -0.13 0.07 -0.24 -0.08 -0.04 -0.12 -0.07 -0.35 -0.30 0.18 0.30
Intent Steal 0.17 0.10 0.48 0.46 0.14 -0.32 0.35 1.00 0.58 -0.24 0.33 -0.24 0.29 -0.30 -0.33 0.07 -0.03 0.00 0.08 0.12 -0.12 -0.09 0.12 0.10 0.04 -0.10 -0.04 -0.05 0.10 -0.01 -0.17 -0.03 0.09 0.13 -0.24 0.17 -0.07 0.05 0.05 0.03 0.03 -0.05 -0.03 0.08 0.28
Intent Stun -0.12 0.39 0.41 0.27 0.28 -0.33 0.14 0.58 1.00 -0.19 0.45 -0.29 0.24 -0.35 -0.22 0.17 0.26 0.22 -0.13 -0.02 0.01 -0.18 0.01 0.00 0.10 -0.03 0.12 0.01 0.01 0.06 -0.06 0.02 0.02 0.02 -0.18 0.14 -0.15 -0.02 0.01 0.02 0.02 0.26 0.06 0.14 0.26
Intent Produce 0.08 -0.13 -0.39 -0.23 -0.32 0.57 -0.11 -0.24 -0.19 1.00 -0.21 0.33 0.05 0.22 0.21 0.12 0.09 0.04 0.01 -0.05 0.07 -0.07 0.05 0.07 0.06 0.10 0.14 -0.08 0.06 0.07 0.00 0.05 -0.06 0.03 0.10 -0.02 0.13 0.06 0.02 0.01 0.04 0.04 -0.05 0.00 -0.05
Intent Raid -0.27 0.29 0.35 -0.07 0.67 -0.48 0.03 0.33 0.45 -0.21 1.00 -0.29 0.22 -0.31 -0.04 0.22 0.27 0.30 -0.02 -0.10 0.08 -0.14 0.08 0.11 0.14 0.10 0.08 -0.08 0.11 0.07 0.04 -0.01 0.06 0.09 -0.13 0.11 -0.03 -0.04 0.04 0.04 -0.02 0.30 0.12 0.03 0.13
Subj: Indiv. -0.03 -0.15 -0.28 -0.10 -0.32 0.24 -0.23 -0.24 -0.29 0.33 -0.29 1.00 0.04 0.16 0.24 0.11 0.03 0.00 0.03 0.14 0.15 0.01 -0.01 0.05 0.06 0.12 0.07 -0.10 -0.03 0.07 0.11 0.01 -0.06 0.06 0.06 0.06 0.16 0.09 0.09 0.08 0.00 -0.04 0.05 -0.05 -0.12
Subj: Compet. 0.26 -0.07 0.14 0.05 0.16 0.02 0.35 0.29 0.24 0.05 0.22 0.04 1.00 -0.35 -0.21 -0.04 0.00 -0.06 0.13 0.14 -0.06 -0.10 0.23 0.24 -0.04 -0.04 -0.03 -0.17 0.24 -0.09 -0.17 -0.17 -0.05 0.22 -0.16 0.11 0.00 0.10 0.04 0.00 0.01 -0.04 -0.04 0.09 0.15
Subj: Prosoc. 0.04 -0.20 -0.32 -0.18 -0.28 0.29 -0.15 -0.30 -0.35 0.22 -0.31 0.16 -0.35 1.00 0.19 -0.06 -0.15 -0.08 0.06 -0.09 -0.05 0.42 0.02 0.01 -0.03 0.03 0.03 0.11 -0.09 0.05 0.00 0.13 0.17 0.05 0.16 0.04 0.10 -0.06 -0.02 0.02 -0.02 -0.12 -0.09 0.03 -0.02
Opp. Capability -0.53 -0.04 -0.37 -0.37 -0.09 0.03 -0.72 -0.33 -0.22 0.21 -0.04 0.24 -0.21 0.19 1.00 0.40 0.29 0.33 0.03 -0.08 0.59 -0.17 -0.03 0.16 0.28 0.68 0.25 -0.37 0.30 0.27 0.40 0.15 0.00 0.02 0.19 -0.07 0.32 0.17 0.08 0.12 0.10 0.21 0.25 -0.12 -0.30
Opp Intent Steal -0.62 0.31 0.08 0.01 0.13 -0.14 -0.48 0.07 0.17 0.12 0.22 0.11 -0.04 -0.06 0.40 1.00 0.72 0.71 -0.30 -0.33 0.41 -0.39 -0.27 -0.09 0.58 0.38 0.37 -0.13 0.08 0.46 0.46 0.25 -0.01 -0.19 0.10 -0.01 0.14 0.09 0.04 0.14 0.04 0.62 0.19 0.05 -0.01
Opp Intent Stun -0.62 0.45 0.12 -0.03 0.24 -0.15 -0.42 -0.03 0.26 0.09 0.27 0.03 0.00 -0.15 0.29 0.72 1.00 0.65 -0.34 -0.42 0.33 -0.41 -0.26 -0.10 0.51 0.29 0.34 -0.06 0.01 0.39 0.38 0.16 -0.03 -0.20 0.07 0.03 0.07 0.07 0.05 0.13 0.02 0.73 0.20 0.05 0.07
Opp Intent Raid -0.62 0.49 0.04 -0.10 0.21 -0.19 -0.38 0.00 0.22 0.04 0.30 0.00 -0.06 -0.08 0.33 0.71 0.65 1.00 -0.30 -0.40 0.32 -0.40 -0.22 -0.10 0.50 0.34 0.32 -0.11 0.04 0.37 0.40 0.20 -0.03 -0.17 0.03 -0.02 0.08 0.01 0.04 0.08 0.04 0.68 0.15 0.03 0.03
Opp Intent Produce 0.31 -0.36 -0.04 -0.01 -0.06 -0.02 0.10 0.08 -0.13 0.01 -0.02 0.03 0.13 0.06 0.03 -0.30 -0.34 -0.30 1.00 0.48 0.08 0.13 0.19 0.21 -0.21 -0.02 -0.12 -0.08 0.09 -0.19 -0.19 -0.06 0.09 0.27 -0.07 0.10 0.05 0.04 -0.02 -0.01 0.15 -0.45 0.02 -0.02 0.04
Opp: Indiv. 0.31 -0.29 0.02 0.12 -0.14 -0.04 0.13 0.12 -0.02 -0.05 -0.10 0.14 0.14 -0.09 -0.08 -0.33 -0.42 -0.40 0.48 1.00 0.07 0.13 0.14 0.10 -0.19 -0.08 -0.22 -0.04 0.04 -0.20 -0.18 -0.07 0.06 0.15 -0.15 0.18 -0.04 0.06 0.07 -0.02 0.00 -0.48 0.01 -0.10 -0.03
Opp: Compet. -0.49 0.03 -0.10 -0.12 0.02 -0.15 -0.60 -0.12 0.01 0.07 0.08 0.15 -0.06 -0.05 0.59 0.41 0.33 0.32 0.08 0.07 1.00 -0.32 -0.14 0.05 0.31 0.54 0.19 -0.25 0.17 0.28 0.40 0.16 -0.06 -0.05 0.02 0.00 0.17 0.14 0.05 0.13 0.05 0.21 0.30 -0.05 -0.14
Opp: Prosoc. 0.38 -0.26 -0.08 -0.04 -0.08 0.12 0.27 -0.09 -0.18 -0.07 -0.14 0.01 -0.10 0.42 -0.17 -0.39 -0.41 -0.40 0.13 0.13 -0.32 1.00 0.19 0.09 -0.26 -0.29 -0.19 0.18 -0.10 -0.21 -0.30 -0.01 0.15 0.15 -0.02 0.15 -0.13 -0.09 -0.03 -0.12 -0.03 -0.41 -0.16 0.13 0.09
Positive 0.32 -0.16 0.01 -0.01 0.02 0.09 0.29 0.12 0.01 0.05 0.08 -0.01 0.23 0.02 -0.03 -0.27 -0.26 -0.22 0.19 0.14 -0.14 0.19 1.00 0.73 -0.37 0.00 -0.22 -0.39 0.42 -0.47 -0.55 -0.29 -0.02 0.72 0.04 0.13 0.12 0.08 0.04 0.06 0.07 -0.22 -0.07 0.04 0.07
Play Again 0.10 -0.12 -0.04 -0.09 0.07 0.00 0.05 0.10 0.00 0.07 0.11 0.05 0.24 0.01 0.16 -0.09 -0.10 -0.10 0.21 0.10 0.05 0.09 0.73 1.00 -0.17 0.25 -0.02 -0.56 0.55 -0.24 -0.30 -0.15 0.00 0.68 -0.04 0.11 0.23 0.09 0.08 0.07 0.08 -0.10 0.00 0.00 0.01
Moral Outrage -0.40 0.30 0.02 -0.05 0.11 -0.15 -0.34 0.04 0.10 0.06 0.14 0.06 -0.04 -0.03 0.28 0.58 0.51 0.50 -0.21 -0.19 0.31 -0.26 -0.37 -0.17 1.00 0.30 0.52 -0.04 0.11 0.66 0.50 0.38 0.10 -0.26 -0.12 0.07 0.05 0.09 0.13 0.06 0.03 0.36 0.19 -0.01 0.07
Challenged -0.51 0.06 -0.22 -0.26 0.01 -0.14 -0.58 -0.10 -0.03 0.10 0.10 0.12 -0.04 0.03 0.68 0.38 0.29 0.34 -0.02 -0.08 0.54 -0.29 0.00 0.25 0.30 1.00 0.36 -0.44 0.42 0.33 0.46 0.16 0.03 0.11 0.08 -0.03 0.29 0.12 0.05 0.09 0.06 0.26 0.22 -0.19 -0.25
Threatened -0.28 0.31 -0.06 -0.13 0.08 0.00 -0.23 -0.04 0.12 0.14 0.08 0.07 -0.03 0.03 0.25 0.37 0.34 0.32 -0.12 -0.22 0.19 -0.19 -0.22 -0.02 0.52 0.36 1.00 -0.03 0.12 0.60 0.47 0.41 0.25 -0.16 -0.01 0.08 -0.02 -0.08 -0.01 -0.19 -0.08 0.29 0.12 -0.03 -0.01
Bored 0.14 0.06 0.13 0.17 -0.02 0.05 0.16 -0.05 0.01 -0.08 -0.08 -0.10 -0.17 0.11 -0.37 -0.13 -0.06 -0.11 -0.08 -0.04 -0.25 0.18 -0.39 -0.56 -0.04 -0.44 -0.03 1.00 -0.65 0.08 0.04 0.14 0.18 -0.35 -0.06 0.12 -0.35 -0.14 -0.05 -0.10 -0.13 -0.08 -0.06 0.07 0.12
Engaged -0.02 -0.06 -0.13 -0.17 0.02 -0.02 -0.06 0.10 0.01 0.06 0.11 -0.03 0.24 -0.09 0.30 0.08 0.01 0.04 0.09 0.04 0.17 -0.10 0.42 0.55 0.11 0.42 0.12 -0.65 1.00 -0.01 -0.02 -0.08 -0.02 0.42 0.01 -0.01 0.31 0.21 0.15 0.07 0.09 -0.01 0.10 -0.11 -0.07
Angry -0.37 0.26 0.01 -0.06 0.11 -0.07 -0.32 -0.01 0.06 0.07 0.07 0.07 -0.09 0.05 0.27 0.46 0.39 0.37 -0.19 -0.20 0.28 -0.21 -0.47 -0.24 0.66 0.33 0.60 0.08 -0.01 1.00 0.66 0.53 0.21 -0.37 -0.04 0.06 0.03 -0.05 0.01 -0.05 -0.01 0.35 0.16 -0.02 0.00
Frustrated -0.56 0.24 -0.10 -0.13 0.03 -0.16 -0.55 -0.17 -0.06 0.00 0.04 0.11 -0.17 0.00 0.40 0.46 0.38 0.40 -0.19 -0.18 0.40 -0.30 -0.55 -0.30 0.50 0.46 0.47 0.04 -0.02 0.66 1.00 0.41 0.07 -0.42 0.02 -0.09 0.06 -0.07 -0.03 -0.08 -0.02 0.40 0.19 -0.14 -0.21
Sad -0.18 0.11 -0.02 -0.01 -0.02 -0.01 -0.20 -0.03 0.02 0.05 -0.01 0.01 -0.17 0.13 0.15 0.25 0.16 0.20 -0.06 -0.07 0.16 -0.01 -0.29 -0.15 0.38 0.16 0.41 0.14 -0.08 0.53 0.41 1.00 0.37 -0.19 0.02 0.13 -0.07 -0.10 0.00 -0.16 -0.12 0.16 0.11 -0.06 -0.01
Guilty 0.09 0.01 0.02 0.00 0.03 0.00 0.07 0.09 0.02 -0.06 0.06 -0.06 -0.05 0.17 0.00 -0.01 -0.03 -0.03 0.09 0.06 -0.06 0.15 -0.02 0.00 0.10 0.03 0.25 0.18 -0.02 0.21 0.07 0.37 1.00 0.03 0.02 0.08 -0.06 -0.14 -0.08 -0.18 -0.06 -0.07 0.05 -0.09 -0.08
Happy 0.22 -0.14 0.00 0.00 0.01 -0.03 0.17 0.13 0.02 0.03 0.09 0.06 0.22 0.05 0.02 -0.19 -0.20 -0.17 0.27 0.15 -0.05 0.15 0.72 0.68 -0.26 0.11 -0.16 -0.35 0.42 -0.37 -0.42 -0.19 0.03 1.00 0.00 0.14 0.13 0.09 0.14 0.11 0.09 -0.22 -0.01 -0.01 0.07
SVO -0.09 -0.01 -0.20 -0.18 -0.08 0.14 -0.13 -0.24 -0.18 0.10 -0.13 0.06 -0.16 0.16 0.19 0.10 0.07 0.03 -0.07 -0.15 0.02 -0.02 0.04 -0.04 -0.12 0.08 -0.01 -0.06 0.01 -0.04 0.02 0.02 0.02 0.00 1.00 -0.19 0.11 -0.06 -0.11 0.00 -0.01 0.08 0.02 0.02 -0.15
Spite 0.05 -0.01 0.09 0.12 -0.01 -0.08 0.07 0.17 0.14 -0.02 0.11 0.06 0.11 0.04 -0.07 -0.01 0.03 -0.02 0.10 0.18 0.00 0.15 0.13 0.11 0.07 -0.03 0.08 0.12 -0.01 0.06 -0.09 0.13 0.08 0.14 -0.19 1.00 -0.24 -0.07 0.04 -0.05 -0.12 -0.06 0.00 0.05 0.19
Agreeableness -0.17 -0.07 -0.18 -0.16 -0.07 0.00 -0.24 -0.07 -0.15 0.13 -0.03 0.16 0.00 0.10 0.32 0.14 0.07 0.08 0.05 -0.04 0.17 -0.13 0.12 0.23 0.05 0.29 -0.02 -0.35 0.31 0.03 0.06 -0.07 -0.06 0.13 0.11 -0.24 1.00 0.36 0.20 0.42 0.24 0.05 0.05 -0.10 -0.09
Conscientiousness -0.05 -0.05 -0.08 0.00 -0.12 -0.08 -0.08 0.05 -0.02 0.06 -0.04 0.09 0.10 -0.06 0.17 0.09 0.07 0.01 0.04 0.06 0.14 -0.09 0.08 0.09 0.09 0.12 -0.08 -0.14 0.21 -0.05 -0.07 -0.10 -0.14 0.09 -0.06 -0.07 0.36 1.00 0.27 0.49 0.14 0.01 0.04 -0.07 0.04
Extraversion -0.02 -0.04 -0.02 -0.02 -0.01 -0.05 -0.04 0.05 0.01 0.02 0.04 0.09 0.04 -0.02 0.08 0.04 0.05 0.04 -0.02 0.07 0.05 -0.03 0.04 0.08 0.13 0.05 -0.01 -0.05 0.15 0.01 -0.03 0.00 -0.08 0.14 -0.11 0.04 0.20 0.27 1.00 0.26 0.24 -0.05 0.10 -0.04 0.06
Emo. Stability -0.13 0.04 0.00 0.03 -0.05 -0.11 -0.12 0.03 0.02 0.01 0.04 0.08 0.00 0.02 0.12 0.14 0.13 0.08 -0.01 -0.02 0.13 -0.12 0.06 0.07 0.06 0.09 -0.19 -0.10 0.07 -0.05 -0.08 -0.16 -0.18 0.11 0.00 -0.05 0.42 0.49 0.26 1.00 0.24 0.10 0.10 0.02 0.12
Openness -0.04 -0.02 0.00 0.03 -0.04 -0.02 -0.07 0.03 0.02 0.04 -0.02 0.00 0.01 -0.02 0.10 0.04 0.02 0.04 0.15 0.00 0.05 -0.03 0.07 0.08 0.03 0.06 -0.08 -0.13 0.09 -0.01 -0.02 -0.12 -0.06 0.09 -0.01 -0.12 0.24 0.14 0.24 0.24 1.00 -0.01 0.04 0.04 0.08
NPC Hostile -0.71 0.58 0.07 -0.09 0.24 -0.17 -0.35 -0.05 0.26 0.04 0.30 -0.04 -0.04 -0.12 0.21 0.62 0.73 0.68 -0.45 -0.48 0.21 -0.41 -0.22 -0.10 0.36 0.26 0.29 -0.08 -0.01 0.35 0.40 0.16 -0.07 -0.22 0.08 -0.06 0.05 0.01 -0.05 0.10 -0.01 1.00 0.02 0.05 0.04
NPC Capable -0.13 0.10 0.14 0.01 0.21 -0.06 -0.30 -0.03 0.06 -0.05 0.12 0.05 -0.04 -0.09 0.25 0.19 0.20 0.15 0.02 0.01 0.30 -0.16 -0.07 0.00 0.19 0.22 0.12 -0.06 0.10 0.16 0.19 0.11 0.05 -0.01 0.02 0.00 0.05 0.04 0.10 0.10 0.04 0.02 1.00 -0.06 -0.03
Game Freq. 0.10 0.07 0.10 0.05 0.08 0.05 0.18 0.08 0.14 0.00 0.03 -0.05 0.09 0.03 -0.12 0.05 0.05 0.03 -0.02 -0.10 -0.05 0.13 0.04 0.00 -0.01 -0.19 -0.03 0.07 -0.11 -0.02 -0.14 -0.06 -0.09 -0.01 0.02 0.05 -0.10 -0.07 -0.04 0.02 0.04 0.05 -0.06 1.00 0.68
Skill Level 0.18 0.12 0.25 0.18 0.15 -0.02 0.30 0.28 0.26 -0.05 0.13 -0.12 0.15 -0.02 -0.30 -0.01 0.07 0.03 0.04 -0.03 -0.14 0.09 0.07 0.01 0.07 -0.25 -0.01 0.12 -0.07 0.00 -0.21 -0.01 -0.08 0.07 -0.15 0.19 -0.09 0.04 0.06 0.12 0.08 0.04 -0.03 0.68 1.00

6.1 Condition × Outcomes Heatmap

outcome_names <- c("Score","Stuns","Instr. Agg.","Steals","Raids","Shakes","Score Diff",
                   "Intent Steal","Intent Stun","Intent Produce","Intent Raid",
                   "Subj: Indiv.","Subj: Compet.","Subj: Prosoc.",
                   "Opp. Capability","Opp Intent Steal","Opp Intent Stun",
                   "Opp Intent Raid","Opp Intent Produce",
                   "Opp: Indiv.","Opp: Compet.","Opp: Prosoc.",
                   "Positive","Play Again","Moral Outrage",
                   "Challenged","Threatened","Bored","Engaged",
                   "Angry","Frustrated","Sad","Guilty","Happy")

ggcorrplot(cor_mat[c("NPC Hostile","NPC Capable"), outcome_names],
           method   = "square",
           lab      = TRUE,
           lab_size = 2.8,
           colors   = c("#E07B54","white","#5B8DB8"),
           title    = "Condition × All Outcomes (r)",
           ggtheme  = theme_minimal()) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 7))


7. Main Effects: Condition → Behavioral Outcomes

outcomes_beh <- list(
  "Total Aggression"  = "total_player_agg",
  "Non-Instr. Stuns"  = "noninstr_aggression",
  "Instr. Aggression" = "instr_aggression",
  "Shakes"            = "shake_count",
  "Score"             = "player_score",
  "Score Diff"        = "score_diff"
)

beh_anova_table <- map_dfr(names(outcomes_beh), function(nm) {
  formula <- as.formula(paste(outcomes_beh[[nm]], "~ hostile * capable"))
  m <- aov(formula, data = df)
  s <- summary(m)[[1]]
  tibble(
    Outcome = nm,
    `Hostile F`   = round(s$`F value`[1], 3),
    `Hostile p`   = round(s$`Pr(>F)`[1],  3),
    `Capable F`   = round(s$`F value`[2], 3),
    `Capable p`   = round(s$`Pr(>F)`[2],  3),
    `Interact. F` = round(s$`F value`[3], 3),
    `Interact. p` = round(s$`Pr(>F)`[3],  3)
  )
})

kable(beh_anova_table,
      caption = "2×2 ANOVA results: Condition → Behavioral Outcomes") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
2×2 ANOVA results: Condition → Behavioral Outcomes
Outcome Hostile F Hostile p Capable F Capable p Interact. F Interact. p
Total Aggression 120.238 0.000 11.334 0.001 6.712 0.010
Non-Instr. Stuns 186.808 0.000 4.696 0.031 2.066 0.151
Instr. Aggression 2.055 0.153 7.141 0.008 5.335 0.021
Shakes 11.319 0.001 1.304 0.254 4.641 0.032
Score 386.432 0.000 9.393 0.002 12.062 0.001
Score Diff 55.326 0.000 39.170 0.000 1.254 0.264

Interpretation:

Total Aggression: both main effects and the interaction are significant. Hostility drives aggression (F = 120, p < .001), capability amplifies it (F = 11, p = .001), and the interaction confirms capability matters more when the NPC is hostile (F = 6.7, p = .010).

Non-Instrumental Stuns: hostility is the dominant predictor (F = 187, p < .001). Capability also adds a small independent effect (F = 4.7, p = .031) but no interaction — capable opponents get stunned more regardless of their hostility level, possibly because they’re more threatening even when benign.

Instrumental Aggression: Hostility does NOT predict instrumental aggression (F = 2.1, p = .153) — players don’t steal/raid more just because the NPC is hostile. But capability does (F = 7.1, p = .008), and there’s a significant interaction (F = 5.3, p = .021). This suggests instrumental aggression is a strategic response to a capable opponent rather than a retaliatory response to a hostile one — players steal and raid when it’s worth doing, not just when they’re angry.

Shakes: hostility significantly reduces shaking (F = 11.3, p = .001) — players produce less fruit when under attack, which makes sense since they’re busy retaliating. The interaction (F = 4.6, p = .032) suggests this suppression of shaking is especially pronounced in one specific condition.

Score: massive effects of both hostility (F = 386, p < .001) and capability (F = 9.4, p = .002), plus a significant interaction (F = 12.1, p = .001). Score is heavily determined by what the NPC does to you.

Score Diff: both main effects significant, no interaction. Hostile and capable NPCs both close the gap independently without amplifying each other for the margin.

7.1 Figures: Behavioral Outcomes by Condition

plot_2x2 <- function(var, label) {
  df %>%
    group_by(hostile, capable) %>%
    summarise(M  = mean(as.numeric(.data[[var]]), na.rm = TRUE),
              SE = sd(as.numeric(.data[[var]]),   na.rm = TRUE) / sqrt(n()),
              .groups = "drop") %>%
    ggplot(aes(x = capable, y = M, fill = hostile, group = hostile)) +
    geom_col(position = position_dodge(0.6), width = 0.5) +
    geom_errorbar(aes(ymin = M - SE, ymax = M + SE),
                  position = position_dodge(0.6), width = 0.2) +
    scale_fill_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                      name = "NPC Hostile") +
    labs(title = label, x = "NPC Capable", y = "Mean") +
    theme_minimal(base_size = 10) +
    theme(legend.position = "bottom")
}

p1 <- plot_2x2("total_player_agg",    "Total Aggression")
p2 <- plot_2x2("noninstr_aggression", "Non-Instr. Stuns")
p3 <- plot_2x2("instr_aggression",    "Instr. Aggression (Steals + Raids)")
p4 <- plot_2x2("shake_count",         "Shakes")
p5 <- plot_2x2("player_score",        "Player Score")

grid.arrange(p1, p2, p3, p4, p5, ncol = 3)


8. Main Effects: Condition → Opponent Perceptions

perc_anova_table <- map_dfr(
  list("Opp. Capability"    = "opp_capability",
       "Opp. Intent Steal"  = "op_intent_steal_n",
       "Opp. Intent Stun"   = "op_intent_stun_n",
       "Opp. Intent Produce"= "op_intent_produce_n",
       "Opp. Intent Raid"= "op_intent_raid_n",
       "Opp: Individualistic"= "opp_subj_intent_1",
       "Opp: Competitive"   = "opp_subj_intent_2",
       "Opp: Prosocial"     = "opp_subj_intent_3"),
  function(var) {
    m <- aov(as.formula(paste(var, "~ hostile * capable")), data = df)
    s <- summary(m)[[1]]
    tibble(`Hostile F`   = round(s$`F value`[1], 3),
           `Hostile p`   = round(s$`Pr(>F)`[1],  3),
           `Capable F`   = round(s$`F value`[2], 3),
           `Capable p`   = round(s$`Pr(>F)`[2],  3),
           `Interact. F` = round(s$`F value`[3], 3),
           `Interact. p` = round(s$`Pr(>F)`[3],  3))
  }, .id = "Outcome"
)

kable(perc_anova_table,
      caption = "2×2 ANOVA results: Condition → Opponent Perceptions") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
2×2 ANOVA results: Condition → Opponent Perceptions
Outcome Hostile F Hostile p Capable F Capable p Interact. F Interact. p
Opp. Capability 17.498 0 25.095 0.000 1.619 0.204
Opp. Intent Steal 250.614 0 19.504 0.000 18.557 0.000
Opp. Intent Stun 490.642 0 29.719 0.000 27.430 0.000
Opp. Intent Produce 92.619 0 0.495 0.482 2.357 0.126
Opp. Intent Raid 328.192 0 12.562 0.000 7.062 0.008
Opp: Individualistic 112.678 0 0.296 0.587 1.373 0.242
Opp: Competitive 18.357 0 36.798 0.000 3.083 0.080
Opp: Prosocial 76.974 0 10.107 0.002 1.205 0.273
# Figure: perceived capability and intent
p_cap <- plot_2x2("opp_capability",    "Perceived Opp. Capability")
p_stl <- plot_2x2("op_intent_steal_n", "Perceived Opp. Intent: Steal")
p_stn <- plot_2x2("op_intent_stun_n",  "Perceived Opp. Intent: Stun")
p_prd <- plot_2x2("op_intent_produce_n","Perceived Opp. Intent: Produce")
p_raid <- plot_2x2("op_intent_raid_n","Perceived Opp. Intent: Raid")
grid.arrange(p_cap, p_stl, p_stn, p_prd, p_raid, ncol = 2)


9. Main Effects: Condition → Emotions & Experience

emo_anova_table <- map_dfr(
  list("Positive"      = "positive_n",
       "Play Again"    = "play_again_n",
       "Moral Outrage" = "moral_outrage",
       "Challenged"    = "challenged_n",
       "Threatened"    = "threatened_n",
       "Bored"         = "bored_n",
       "Engaged"       = "engaged_n",
       "Angry"         = "angry_n",
       "Frustrated"    = "frustrated_n",
       "Sad"           = "sad_n",
       "Guilty"        = "guilty_n",
       "Happy"         = "happy_n"),
  function(var) {
    m <- aov(as.formula(paste(var, "~ hostile * capable")), data = df)
    s <- summary(m)[[1]]
    tibble(`Hostile F`   = round(s$`F value`[1], 3),
           `Hostile p`   = round(s$`Pr(>F)`[1],  3),
           `Capable F`   = round(s$`F value`[2], 3),
           `Capable p`   = round(s$`Pr(>F)`[2],  3),
           `Interact. F` = round(s$`F value`[3], 3),
           `Interact. p` = round(s$`Pr(>F)`[3],  3))
  }, .id = "Outcome"
)

kable(emo_anova_table,
      caption = "2×2 ANOVA results: Condition → Emotions & Experience") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
2×2 ANOVA results: Condition → Emotions & Experience
Outcome Hostile F Hostile p Capable F Capable p Interact. F Interact. p
Positive 18.514 0.000 1.842 0.176 0.710 0.400
Play Again 4.087 0.044 0.001 0.980 2.109 0.147
Moral Outrage 58.292 0.000 14.618 0.000 4.622 0.032
Challenged 27.359 0.000 19.771 0.000 0.329 0.566
Threatened 33.966 0.000 5.031 0.025 0.557 0.456
Bored 2.574 0.110 1.176 0.279 0.757 0.385
Engaged 0.036 0.850 3.640 0.057 1.553 0.213
Angry 53.640 0.000 9.907 0.002 3.795 0.052
Frustrated 72.022 0.000 15.865 0.000 4.914 0.027
Sad 9.150 0.003 4.447 0.036 0.279 0.598
Guilty 1.775 0.184 1.011 0.315 0.003 0.953
Happy 17.802 0.000 0.002 0.968 0.284 0.594
# Emotion profiles by condition
df %>%
  dplyr::select(cond_4, angry_n, frustrated_n, threatened_n, guilty_n,
                challenged_n, engaged_n, happy_n, bored_n,
                positive_n, moral_outrage) %>%
  pivot_longer(-cond_4, names_to = "Emotion", values_to = "Score") %>%
  mutate(Emotion = recode(Emotion,
    "angry_n"       = "Angry",   "frustrated_n" = "Frustrated",
    "threatened_n"  = "Threatened", "guilty_n"   = "Guilty",
    "challenged_n"  = "Challenged", "engaged_n"  = "Engaged",
    "happy_n"       = "Happy",   "bored_n"      = "Bored",
    "positive_n"    = "Positive","moral_outrage" = "Moral Outrage"
  )) %>%
  group_by(cond_4, Emotion) %>%
  summarise(M = mean(as.numeric(Score), na.rm = TRUE), .groups = "drop") %>%
  ggplot(aes(x = Emotion, y = M, fill = cond_4)) +
  geom_col(position = position_dodge(0.7), width = 0.6) +
  scale_fill_manual(values = c("low × low"   = "#5B8DB8",
                                "low × high"  = "#A8C8E0",
                                "high × low"  = "#F0C274",
                                "high × high" = "#E07B54"),
                    name = "Hostile × Capable") +
  scale_y_continuous(limits = c(0, 7)) +
  labs(title = "Emotion Profile by Condition",
       x = NULL, y = "Mean (1–7)") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 35, hjust = 1),
        legend.position = "top")

Hostility drives broad negative emotional responding while capability specifically amplifies outrage and frustration — and their combination in the high × high cell produces the most intense negative emotional experience.


10. Interaction Effects: The Ambiguous Cells

The two “interesting” cells — Low Hostile × High Capable and High Hostile × Low Capable — are where individual differences are predicted to determine the course. Here we examine whether the interaction patterns match predictions.

10.1 Interaction: Total Aggression

m_int_agg <- lm(total_player_agg ~ hostile * capable, data = df)
summary(m_int_agg)
## 
## Call:
## lm(formula = total_player_agg ~ hostile * capable, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -14.615  -5.000  -1.148   4.000  41.385 
## 
## Coefficients:
##                         Estimate Std. Error t value Pr(>|t|)    
## (Intercept)               6.0000     0.7182   8.354 1.38e-15 ***
## hostilehigh               6.1477     1.0441   5.888 8.85e-09 ***
## capablehigh               0.6344     1.0292   0.616  0.53802    
## hostilehigh:capablehigh   3.8332     1.4796   2.591  0.00996 ** 
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 7.11 on 366 degrees of freedom
## Multiple R-squared:  0.2742, Adjusted R-squared:  0.2683 
## F-statistic: 46.09 on 3 and 366 DF,  p-value: < 2.2e-16
# Interaction plot
df %>%
  group_by(hostile, capable) %>%
  summarise(M  = mean(total_player_agg, na.rm = TRUE),
            SE = sd(total_player_agg,   na.rm = TRUE) / sqrt(n()),
            .groups = "drop") %>%
  ggplot(aes(x = hostile, y = M, color = capable, group = capable)) +
  geom_line(linewidth = 1) +
  geom_point(size = 3) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE), width = 0.1) +
  scale_color_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                     name = "NPC Capable") +
  labs(title = "Total Aggression: Hostile × Capable Interaction",
       subtitle = "Error bars = ±1 SE",
       x = "NPC Hostility", y = "Mean Total Aggression") +
  theme_minimal()

Hostility is the primary trigger for aggression (adding ~6 acts), capability alone adds nothing when the NPC is benign, but the hostile × capable combination produces a synergistic additional 3.8 acts beyond additive expectations.

10.2 Interaction: Moral Outrage

m_int_out <- lm(moral_outrage ~ hostile * capable, data = df)
summary(m_int_out)
## 
## Call:
## lm(formula = moral_outrage ~ hostile * capable, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -1.3883 -0.7008 -0.2832 -0.0393  5.2992 
## 
## Coefficients:
##                         Estimate Std. Error t value Pr(>|t|)    
## (Intercept)               1.0850     0.1105   9.820  < 2e-16 ***
## hostilehigh               0.6157     0.1606   3.833 0.000149 ***
## capablehigh               0.1981     0.1583   1.251 0.211670    
## hostilehigh:capablehigh   0.4894     0.2276   2.150 0.032215 *  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1.094 on 366 degrees of freedom
## Multiple R-squared:  0.1748, Adjusted R-squared:  0.168 
## F-statistic: 25.84 on 3 and 366 DF,  p-value: 3.477e-15
df %>%
  group_by(hostile, capable) %>%
  summarise(M  = mean(moral_outrage, na.rm = TRUE),
            SE = sd(moral_outrage,   na.rm = TRUE) / sqrt(n()),
            .groups = "drop") %>%
  ggplot(aes(x = hostile, y = M, color = capable, group = capable)) +
  geom_line(linewidth = 1) +
  geom_point(size = 3) +
  geom_errorbar(aes(ymin = M - SE, ymax = M + SE), width = 0.1) +
  scale_color_manual(values = c("low" = "#5B8DB8","high" = "#E07B54"),
                     name = "NPC Capable") +
  labs(title = "Moral Outrage: Hostile × Capable Interaction",
       x = "NPC Hostility", y = "Mean Moral Outrage") +
  theme_minimal()

Moral outrage follows the same hostile × capable interaction as aggression — requiring both malicious intent and competence to peak.


11. Individual Differences as Moderators

Predicted to matter most in the two ambiguous cells: Low Hostile × High Capable (“Matching” vs “Force my hand”) High Hostile × Low Capable (“Benign paternalism” vs “Put them in their place”)

11.1 SVO × Condition → Total Aggression

m_svo_agg <- lm(total_player_agg ~ hostile * capable * svo_c, data = df)
summary(m_svo_agg)
## 
## Call:
## lm(formula = total_player_agg ~ hostile * capable * svo_c, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -13.877  -4.284  -1.220   4.159  40.160 
## 
## Coefficients:
##                               Estimate Std. Error t value Pr(>|t|)    
## (Intercept)                     5.7959     0.7100   8.163 5.49e-15 ***
## hostilehigh                     6.4320     1.0312   6.238 1.24e-09 ***
## capablehigh                     0.8025     1.0157   0.790  0.43003    
## svo_c                          -2.0091     0.6948  -2.892  0.00406 ** 
## hostilehigh:capablehigh         3.7298     1.4604   2.554  0.01106 *  
## hostilehigh:svo_c               0.8088     1.1040   0.733  0.46426    
## capablehigh:svo_c               1.3146     1.0240   1.284  0.20005    
## hostilehigh:capablehigh:svo_c  -1.5916     1.4902  -1.068  0.28622    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 6.994 on 362 degrees of freedom
## Multiple R-squared:  0.3053, Adjusted R-squared:  0.2918 
## F-statistic: 22.72 on 7 and 362 DF,  p-value: < 2.2e-16
# Three-way interaction plot: SVO moderating the 2x2
interact_plot(m_svo_agg,
              pred        = svo_c,
              modx        = hostile,
              mod2        = capable,
              modx.labels = c("Low Hostile","High Hostile"),
              mod2.labels = c("Low Capable","High Capable"),
              x.label     = "SVO Angle (centered)",
              y.label     = "Total Aggression",
              main.title  = "SVO × Hostile × Capable on Total Aggression") +
  theme_minimal()

SVO predicts less aggression overall (more prosocial = less aggressive) but doesn’t differentially moderate how people respond to the specific combination of hostility and capability — it’s a trait-level suppressor rather than a context-sensitive moderator.

11.2 Spite × Condition → Total Aggression

m_spite_agg <- lm(total_player_agg ~ hostile * capable * spite_c, data = df)
summary(m_spite_agg)
## 
## Call:
## lm(formula = total_player_agg ~ hostile * capable * spite_c, 
##     data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -15.862  -4.640  -1.088   4.303  41.377 
## 
## Coefficients:
##                                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)                       5.9210     0.7181   8.245 3.08e-15 ***
## hostilehigh                       6.3713     1.0454   6.095 2.81e-09 ***
## capablehigh                       0.7143     1.0283   0.695   0.4878    
## spite_c                           1.0730     0.6988   1.536   0.1255    
## hostilehigh:capablehigh           3.6074     1.4791   2.439   0.0152 *  
## hostilehigh:spite_c               0.5193     1.0918   0.476   0.6346    
## capablehigh:spite_c              -1.0930     1.0430  -1.048   0.2954    
## hostilehigh:capablehigh:spite_c  -0.5389     1.5007  -0.359   0.7197    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 7.091 on 362 degrees of freedom
## Multiple R-squared:  0.286,  Adjusted R-squared:  0.2722 
## F-statistic: 20.71 on 7 and 362 DF,  p-value: < 2.2e-16
interact_plot(m_spite_agg,
              pred        = spite_c,
              modx        = hostile,
              mod2        = capable,
              modx.labels = c("Low Hostile","High Hostile"),
              mod2.labels = c("Low Capable","High Capable"),
              x.label     = "Spite (centered)",
              y.label     = "Total Aggression",
              main.title  = "Spite × Hostile × Capable on Total Aggression") +
  theme_minimal()

Spite drives aggression specifically when the opponent is hostile but weak — a “pile on” pattern — but when the opponent is highly capable the situation dominates and individual differences in spite become irrelevant.

11.3 Agreeableness × Condition → Total Aggression

m_agree_agg <- lm(total_player_agg ~ hostile * capable * agree_c, data = df)
summary(m_agree_agg)
## 
## Call:
## lm(formula = total_player_agg ~ hostile * capable * agree_c, 
##     data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -15.711  -4.429  -1.171   3.832  41.951 
## 
## Coefficients:
##                                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)                       5.9013     0.7009   8.420 8.92e-16 ***
## hostilehigh                       6.2008     1.0173   6.095 2.80e-09 ***
## capablehigh                       0.7205     1.0028   0.718  0.47295    
## agree_c                          -1.1614     0.6660  -1.744  0.08206 .  
## hostilehigh:capablehigh           3.9619     1.4430   2.746  0.00634 ** 
## hostilehigh:agree_c              -1.9751     1.0159  -1.944  0.05266 .  
## capablehigh:agree_c               0.3362     1.0130   0.332  0.74019    
## hostilehigh:capablehigh:agree_c   1.4063     1.4540   0.967  0.33411    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 6.916 on 362 degrees of freedom
## Multiple R-squared:  0.3208, Adjusted R-squared:  0.3076 
## F-statistic: 24.42 on 7 and 362 DF,  p-value: < 2.2e-16
interact_plot(m_agree_agg,
              pred        = agree_c,
              modx        = hostile,
              mod2        = capable,
              modx.labels = c("Low Hostile","High Hostile"),
              mod2.labels = c("Low Capable","High Capable"),
              x.label     = "Agreeableness (centered)",
              y.label     = "Total Aggression",
              main.title  = "Agreeableness × Hostile × Capable on Total Aggression") +
  theme_minimal()

Highly agreeable people retaliate substantially less when provoked by a hostile opponent.

11.4 Individual Differences in the Ambiguous Cells

Focused analyses within each ambiguous cell.

Stronger predictions

# Low Hostile × High Capable: "Matching" vs "Force my hand"
df_lh_hc <- df %>% filter(npc_hostile == "low", npc_capable == "high")
cat("--- Low Hostile × High Capable (N =", nrow(df_lh_hc), ") ---\n")
## --- Low Hostile × High Capable (N = 93 ) ---
cat("SVO → Total Aggression:\n")
## SVO → Total Aggression:
summary(lm(total_player_agg ~ svo_c,   data = df_lh_hc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##     -0.695      0.557     -1.247      0.215
cat("Spite → Total Aggression:\n")
## Spite → Total Aggression:
summary(lm(total_player_agg ~ spite_c, data = df_lh_hc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##     -0.020      0.570     -0.035      0.972
cat("Agreeableness → Total Aggression:\n")
## Agreeableness → Total Aggression:
summary(lm(total_player_agg ~ agree_c, data = df_lh_hc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##     -0.825      0.570     -1.449      0.151
cat("\n--- High Hostile × Low Capable (N =",
    nrow(df %>% filter(npc_hostile=="high", npc_capable=="low")), ") ---\n")
## 
## --- High Hostile × Low Capable (N = 88 ) ---
df_hh_lc <- df %>% filter(npc_hostile == "high", npc_capable == "low")
cat("SVO → Total Aggression:\n")
## SVO → Total Aggression:
summary(lm(total_player_agg ~ svo_c,   data = df_hh_lc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##     -1.200      0.998     -1.203      0.232
cat("Spite → Total Aggression:\n")
## Spite → Total Aggression:
summary(lm(total_player_agg ~ spite_c, data = df_hh_lc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##      1.592      0.955      1.668      0.099
cat("Agreeableness → Total Aggression:\n")
## Agreeableness → Total Aggression:
summary(lm(total_player_agg ~ agree_c, data = df_hh_lc))$coefficients[2,] %>%
  round(3) %>% print()
##   Estimate Std. Error    t value   Pr(>|t|) 
##     -3.136      0.844     -3.714      0.000

Low Hostile × High Capable: nothing predicts aggression. All three individual differences are non-significant (p = .215, .972, .151). When facing a strong but benign opponent, individual differences don’t determine what people do — everyone responds similarly by not aggressing much. The “matching” vs “force my hand” prediction isn’t supported here; the situation is clear enough (not hostile) that traits don’t differentiate behavior.

High Hostile × Low Capable: agreeableness is the only significant predictor (b = −3.14, p < .001). This is a strong effect. When facing a hostile but weak opponent — the “benign paternalism vs. put them in their place” cell — agreeableness cleanly separates those who restrain from those who pile on. Spite trends positive (b = 1.59, p = .099) — spiteful people tend to punish the weak hostile attacker more — but doesn’t reach significance. SVO doesn’t predict at all (p = .232).

The theoretical story this tells: individual differences matter specifically in the high hostile × low capable cell, not the low hostile × high capable cell. The provocative-but-safe situation (hostile but can’t really hurt you) is where personality takes over — agreeable people let it go, disagreeable people retaliate. The capable-but-benign situation doesn’t activate personality differences because there’s nothing to retaliate against.

Individual differences only determine the course in the high hostile × low capable cell — agreeableness strongly predicts restraint when facing a provocative but weak opponent, confirming the “benign paternalism vs. put them in their place” prediction while the low hostile × high capable cell shows no personality effects at all.

11.5 Exploratory: Additional TIPI Traits in the Ambiguous Cells

# Center additional TIPI traits
df <- df %>%
  mutate(
    consc_c  = as.numeric(scale(tipi_conscientiousness)),
    emosta_c = as.numeric(scale(tipi_emo_stability)),
    extra_c  = as.numeric(scale(tipi_extraversion)),
    open_c   = as.numeric(scale(tipi_openness))
  )

# Recreate subsets after centering new variables
df_lh_hc <- df %>% filter(npc_hostile == "low",  npc_capable == "high")
df_hh_lc <- df %>% filter(npc_hostile == "high", npc_capable == "low")

# Full sample: each trait controlling for condition
tipi_full <- map_dfr(
  list(Conscientiousness  = "consc_c",
       Emo_Stability      = "emosta_c",
       Extraversion       = "extra_c",
       Openness           = "open_c"),
  function(var) {
    m <- lm(as.formula(paste("total_player_agg ~", var,
                              "+ hostile + capable")), data = df)
    ct <- summary(m)$coefficients
    tibble(b   = round(ct[2,1], 3),
           se  = round(ct[2,2], 3),
           t   = round(ct[2,3], 3),
           p   = round(ct[2,4], 3),
           sig = case_when(ct[2,4] < .001 ~ "***",
                           ct[2,4] < .01  ~ "**",
                           ct[2,4] < .05  ~ "*",
                           ct[2,4] < .10  ~ ".",
                           TRUE           ~ ""))
  }, .id = "Trait"
)

kable(tipi_full,
      caption = "Additional TIPI traits → Total Aggression (full sample, controlling for condition)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Additional TIPI traits → Total Aggression (full sample, controlling for condition)
Trait b se t p sig
Conscientiousness -0.741 0.372 -1.993 0.047
Emo_Stability -0.309 0.377 -0.820 0.413
Extraversion -0.282 0.376 -0.751 0.453
Openness -0.120 0.374 -0.320 0.749
# Low Hostile × High Capable
cat("--- Low Hostile × High Capable (N =", nrow(df_lh_hc), ") ---\n")
## --- Low Hostile × High Capable (N = 93 ) ---
map_dfr(
  list(Conscientiousness = "consc_c",
       Emo_Stability     = "emosta_c",
       Extraversion      = "extra_c",
       Openness          = "open_c"),
  function(var) {
    m <- lm(as.formula(paste("total_player_agg ~", var)), data = df_lh_hc)
    ct <- summary(m)$coefficients
    tibble(b  = round(ct[2,1], 3),
           se = round(ct[2,2], 3),
           t  = round(ct[2,3], 3),
           p  = round(ct[2,4], 3))
  }, .id = "Trait"
) %>%
  kable(caption = "Low Hostile × High Capable: TIPI traits → Total Aggression") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Low Hostile × High Capable: TIPI traits → Total Aggression
Trait b se t p
Conscientiousness -0.049 0.558 -0.088 0.930
Emo_Stability 0.416 0.539 0.773 0.442
Extraversion 0.037 0.529 0.069 0.945
Openness -0.004 0.536 -0.008 0.994
# High Hostile × Low Capable
cat("--- High Hostile × Low Capable (N =", nrow(df_hh_lc), ") ---\n")
## --- High Hostile × Low Capable (N = 88 ) ---
map_dfr(
  list(Conscientiousness = "consc_c",
       Emo_Stability     = "emosta_c",
       Extraversion      = "extra_c",
       Openness          = "open_c"),
  function(var) {
    m <- lm(as.formula(paste("total_player_agg ~", var)), data = df_hh_lc)
    ct <- summary(m)$coefficients
    tibble(b  = round(ct[2,1], 3),
           se = round(ct[2,2], 3),
           t  = round(ct[2,3], 3),
           p  = round(ct[2,4], 3))
  }, .id = "Trait"
) %>%
  kable(caption = "High Hostile × Low Capable: TIPI traits → Total Aggression") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
High Hostile × Low Capable: TIPI traits → Total Aggression
Trait b se t p
Conscientiousness -0.790 0.798 -0.990 0.325
Emo_Stability -1.365 0.884 -1.545 0.126
Extraversion -0.547 0.888 -0.616 0.540
Openness -1.633 0.908 -1.799 0.076

No additional traits predict aggression in the benign capable cell, but in the hostile weak cell openness and emotional stability trend toward restraint — converging with agreeableness to suggest that “letting it go” when you could retaliate safely reflects a cluster of prosocial and emotionally regulated traits rather than any single disposition.

11.6 Summary: Individual Difference Moderation Table

id_predictors <- list(
  SVO               = "svo_c",
  Spite             = "spite_c",
  Agreeableness     = "agree_c",
  Conscientiousness = "consc_c",
  Emo_Stability     = "emosta_c",
  Extraversion      = "extra_c",
  Openness          = "open_c"
)

beh_outcomes <- list(
  "Total Agg."  = "total_player_agg",
  "Stuns"       = "noninstr_aggression",
  "Instr. Agg." = "instr_aggression",
  "Shakes"      = "shake_count"
)

# Main effect of each ID in full sample
map_dfr(names(id_predictors), function(id_nm) {
  map_dfr(names(beh_outcomes), function(out_nm) {
    m <- lm(as.formula(paste(beh_outcomes[[out_nm]], "~",
                              id_predictors[[id_nm]],
                              "+ hostile + capable")), data = df)
    ct <- summary(m)$coefficients
    tibble(Predictor = id_nm, Outcome = out_nm,
           b   = round(ct[2,1], 3),
           p   = round(ct[2,4], 3),
           sig = case_when(ct[2,4] < .001 ~ "***",
                           ct[2,4] < .01  ~ "**",
                           ct[2,4] < .05  ~ "*",
                           ct[2,4] < .10  ~ ".",
                           TRUE           ~ ""))
  })
}) %>%
  mutate(b_sig = paste0(b, sig)) %>%
  dplyr::select(Predictor, Outcome, b_sig) %>%
  pivot_wider(names_from = Outcome, values_from = b_sig) %>%
  kable(caption = "Individual difference main effects controlling for condition (b, *** p<.001, ** p<.01, * p<.05, . p<.10)") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Individual difference main effects controlling for condition (b, *** p<.001, ** p<.01, * p<.05, . p<.10)
Predictor Total Agg. Stuns Instr. Agg. Shakes
SVO -1.394*** -0.364 -1.03*** 4.362**
Spite 0.614 0.145 0.47. -2.536.
Agreeableness -1.568*** -0.648* -0.92*** 0.196
Conscientiousness -0.741* -0.342 -0.399 -2.002
Emo_Stability -0.309 -0.184 -0.125 -2.352.
Extraversion -0.282 -0.132 -0.151 -1.559
Openness -0.12 -0.112 -0.008 -0.399

12. People or Bot Belief

df %>%
  mutate(pob_label = case_when(
    people_or_bot == 1 ~ "Real person",
    people_or_bot == 2 ~ "Computer",
    people_or_bot == 3 ~ "Unsure",
    TRUE               ~ NA_character_
  )) %>%
  filter(!is.na(pob_label)) %>%
  count(hostile, capable, pob_label) %>%
  group_by(hostile, capable) %>%
  mutate(pct = percent(n / sum(n), 1)) %>%
  kable(col.names = c("Hostile","Capable","Belief","N","%"),
        caption = "Belief about opponent by condition") %>%
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Belief about opponent by condition
Hostile Capable Belief N %
low low Computer 61 62%
low low Real person 28 29%
low low Unsure 9 9%
low high Computer 59 63%
low high Real person 23 25%
low high Unsure 11 12%
high low Computer 55 62%
high low Real person 23 26%
high low Unsure 10 11%
high high Computer 52 57%
high high Real person 27 30%
high high Unsure 12 13%
# Chi-square: does belief differ by condition?
cat("Chi-square: belief × hostile condition\n")
## Chi-square: belief × hostile condition
print(chisq.test(table(df$npc_hostile, df$people_or_bot)))
## 
##  Pearson's Chi-squared test
## 
## data:  table(df$npc_hostile, df$people_or_bot)
## X-squared = 0.46093, df = 2, p-value = 0.7942
cat("Chi-square: belief × capable condition\n")
## Chi-square: belief × capable condition
print(chisq.test(table(df$npc_capable, df$people_or_bot)))
## 
##  Pearson's Chi-squared test
## 
## data:  table(df$npc_capable, df$people_or_bot)
## X-squared = 0.49019, df = 2, p-value = 0.7826

13. Session Info

sessionInfo()
## R version 4.6.0 (2026-04-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Ventura 13.3
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] interactions_1.2.0 scales_1.4.0       gridExtra_2.3      ggcorrplot_0.1.4.1
##  [5] kableExtra_1.4.0   knitr_1.51         psych_2.6.5        lubridate_1.9.5   
##  [9] forcats_1.0.1      stringr_1.6.0      dplyr_1.2.1        purrr_1.2.2       
## [13] readr_2.2.0        tidyr_1.3.2        tibble_3.3.1       ggplot2_4.0.3     
## [17] tidyverse_2.0.0    qualtRics_3.2.2   
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        xfun_0.58           bslib_0.11.0       
##  [4] insight_1.5.1       lattice_0.22-9      tzdb_0.5.0         
##  [7] vctrs_0.7.3         tools_4.6.0         generics_0.1.4     
## [10] parallel_4.6.0      pkgconfig_2.0.3     RColorBrewer_1.1-3 
## [13] S7_0.2.2            lifecycle_1.0.5     compiler_4.6.0     
## [16] farver_2.1.2        textshaping_1.0.5   mnormt_2.1.2       
## [19] codetools_0.2-20    htmltools_0.5.9     sass_0.4.10        
## [22] yaml_2.3.12         crayon_1.5.3        pillar_1.11.1      
## [25] furrr_0.4.0         jquerylib_0.1.4     broom.mixed_0.2.9.7
## [28] cachem_1.1.0        parallelly_1.47.0   nlme_3.1-169       
## [31] tidyselect_1.2.1    sjlabelled_1.2.0    digest_0.6.39      
## [34] future_1.70.0       stringi_1.8.7       reshape2_1.4.5     
## [37] pander_0.6.6        listenv_0.10.1      labeling_0.4.3     
## [40] splines_4.6.0       fastmap_1.2.0       grid_4.6.0         
## [43] cli_3.6.6           magrittr_2.0.5      broom_1.0.13       
## [46] withr_3.0.2         jtools_2.3.1        backports_1.5.1    
## [49] bit64_4.8.2         timechange_0.4.0    rmarkdown_2.31     
## [52] globals_0.19.1      bit_4.6.0           otel_0.2.0         
## [55] hms_1.1.4           evaluate_1.0.5      viridisLite_0.4.3  
## [58] rlang_1.2.0         Rcpp_1.1.1-1.1      glue_1.8.1         
## [61] xml2_1.5.2          vroom_1.7.1         svglite_2.2.2      
## [64] rstudioapi_0.19.0   jsonlite_2.0.0      plyr_1.8.9         
## [67] R6_2.6.1            systemfonts_1.3.2