Building the 2×2 Nash Equilibrium Explorer — a Shiny tutorial

shiny-tutorial
nash-equilibrium
interactive
bimatrix-games
Step-by-step tutorial for building an interactive Shiny app that computes and visualizes Nash equilibria for arbitrary 2×2 bimatrix games, with best-response plots and expected payoff surfaces.
Author

Raban Heller

Published

May 8, 2026

Modified

May 8, 2026

Keywords

Shiny, Nash equilibrium, 2x2 game, best response, interactive, bimatrix

Introduction & motivation

The 2×2 bimatrix game is the simplest non-trivial strategic interaction: two players, each with two strategies, producing a 2×2 payoff matrix for each player. Despite its simplicity, the 2×2 game encompasses the Prisoner’s Dilemma, Battle of the Sexes, Matching Pennies, Stag Hunt, Chicken, and many other canonical games. Computing Nash equilibria for 2×2 games requires checking pure-strategy profiles for mutual best responses and solving a pair of linear indifference equations for mixed equilibria — straightforward mathematics, but tedious to do by hand and hard to visualize. The 2×2 Nash Equilibrium Explorer is a Shiny app that lets users enter arbitrary payoff matrices, instantly see all Nash equilibria (pure and mixed), and explore best-response correspondences and expected payoff surfaces interactively. This tutorial walks through the app’s construction step by step: the equilibrium solver, the reactive UI, the best-response visualization, and the payoff surface plots. Building this app is an exercise in combining game-theoretic computation with interactive web technology — a pattern that recurs throughout this site’s Shiny applications.

The Nash equilibrium solver

The core algorithm checks all four pure-strategy profiles and then solves for the mixed equilibrium.

solve_nash_2x2 <- function(A, B) {
  equilibria <- list()

  # Pure strategy NE: check all four profiles
  for (i in 1:2) {
    for (j in 1:2) {
      # Is i a best response for Row given Col plays j?
      br_row <- (A[i, j] >= A[3 - i, j])
      # Is j a best response for Col given Row plays i?
      br_col <- (B[i, j] >= B[i, 3 - j])

      if (br_row && br_col) {
        equilibria <- c(equilibria, list(list(
          type = "Pure",
          p = ifelse(i == 1, 1, 0),  # P(Row plays Top)
          q = ifelse(j == 1, 1, 0),  # P(Col plays Left)
          row_payoff = A[i, j],
          col_payoff = B[i, j]
        )))
      }
    }
  }

  # Mixed strategy NE
  # Row indifference: q*A[1,1] + (1-q)*A[1,2] = q*A[2,1] + (1-q)*A[2,2]
  denom_q <- (A[1,1] - A[2,1]) - (A[1,2] - A[2,2])
  # Col indifference: p*B[1,1] + (1-p)*B[2,1] = p*B[1,2] + (1-p)*B[2,2]
  denom_p <- (B[1,1] - B[2,1]) - (B[1,2] - B[2,2])

  if (abs(denom_q) > 1e-10 && abs(denom_p) > 1e-10) {
    q_star <- (A[2,2] - A[1,2]) / denom_q
    p_star <- (B[2,2] - B[2,1]) / denom_p

    if (p_star > 0 && p_star < 1 && q_star > 0 && q_star < 1) {
      row_payoff <- p_star * (q_star * A[1,1] + (1 - q_star) * A[1,2]) +
                    (1 - p_star) * (q_star * A[2,1] + (1 - q_star) * A[2,2])
      col_payoff <- p_star * (q_star * B[1,1] + (1 - q_star) * B[1,2]) +
                    (1 - p_star) * (q_star * B[2,1] + (1 - q_star) * B[2,2])

      equilibria <- c(equilibria, list(list(
        type = "Mixed",
        p = round(p_star, 4),
        q = round(q_star, 4),
        row_payoff = round(row_payoff, 4),
        col_payoff = round(col_payoff, 4)
      )))
    }
  }

  equilibria
}

# --- Test with classic games ---
cat("=== Prisoner's Dilemma ===\n")
=== Prisoner's Dilemma ===
A_pd <- matrix(c(3, 5, 0, 1), nrow = 2)
B_pd <- matrix(c(3, 0, 5, 1), nrow = 2)
pd_eq <- solve_nash_2x2(A_pd, B_pd)
for (eq in pd_eq) cat(sprintf("  %s NE: p=%.2f, q=%.2f, payoffs=(%.2f, %.2f)\n",
                                eq$type, eq$p, eq$q, eq$row_payoff, eq$col_payoff))
  Pure NE: p=0.00, q=0.00, payoffs=(1.00, 1.00)
cat("\n=== Battle of the Sexes ===\n")

=== Battle of the Sexes ===
A_bos <- matrix(c(3, 0, 0, 2), nrow = 2)
B_bos <- matrix(c(2, 0, 0, 3), nrow = 2)
bos_eq <- solve_nash_2x2(A_bos, B_bos)
for (eq in bos_eq) cat(sprintf("  %s NE: p=%.2f, q=%.2f, payoffs=(%.2f, %.2f)\n",
                                eq$type, eq$p, eq$q, eq$row_payoff, eq$col_payoff))
  Pure NE: p=1.00, q=1.00, payoffs=(3.00, 2.00)
  Pure NE: p=0.00, q=0.00, payoffs=(2.00, 3.00)
  Mixed NE: p=0.60, q=0.40, payoffs=(1.20, 1.20)
cat("\n=== Matching Pennies ===\n")

=== Matching Pennies ===
A_mp <- matrix(c(1, -1, -1, 1), nrow = 2)
B_mp <- matrix(c(-1, 1, 1, -1), nrow = 2)
mp_eq <- solve_nash_2x2(A_mp, B_mp)
for (eq in mp_eq) cat(sprintf("  %s NE: p=%.2f, q=%.2f, payoffs=(%.2f, %.2f)\n",
                                eq$type, eq$p, eq$q, eq$row_payoff, eq$col_payoff))
  Mixed NE: p=0.50, q=0.50, payoffs=(0.00, 0.00)

Best response visualization

A <- A_bos; B <- B_bos

# Row player BR
q_seq <- seq(0, 1, length.out = 500)
row_br <- sapply(q_seq, function(q) {
  coeff <- q * (A[1,1] - A[2,1]) + (1 - q) * (A[1,2] - A[2,2])
  if (abs(coeff) < 1e-10) return(NA)
  if (coeff > 0) return(1) else return(0)
})

# Column player BR
p_seq <- seq(0, 1, length.out = 500)
col_br <- sapply(p_seq, function(p) {
  coeff <- p * (B[1,1] - B[1,2]) + (1 - p) * (B[2,1] - B[2,2])
  if (abs(coeff) < 1e-10) return(NA)
  if (coeff > 0) return(1) else return(0)
})

row_br_df <- tibble(q = q_seq, p_br = row_br) |> filter(!is.na(p_br))
col_br_df <- tibble(p = p_seq, q_br = col_br) |> filter(!is.na(q_br))

# Add vertical segments for BRs at indifference points
q_indiff <- (A[2,2] - A[1,2]) / ((A[1,1] - A[2,1]) - (A[1,2] - A[2,2]))
p_indiff <- (B[2,2] - B[2,1]) / ((B[1,1] - B[2,1]) - (B[1,2] - B[2,2]))

eq_points <- do.call(rbind, lapply(bos_eq, function(e) {
  data.frame(q = e$q, p = e$p, label = paste0(e$type, ": (", e$p, ", ", e$q, ")"))
}))

ggplot() +
  # Row BR (blue)
  geom_step(data = row_br_df, aes(x = q, y = p_br), color = okabe_ito[5],
            linewidth = 1.5, direction = "mid") +
  geom_segment(aes(x = q_indiff, xend = q_indiff, y = 0, yend = 1),
               color = okabe_ito[5], linewidth = 1.5) +
  # Col BR (red)
  geom_step(data = col_br_df, aes(x = q_br, y = p), color = okabe_ito[6],
            linewidth = 1.5, direction = "mid") +
  geom_segment(aes(x = 0, xend = 1, y = p_indiff, yend = p_indiff),
               color = okabe_ito[6], linewidth = 1.5) +
  # Equilibrium points
  geom_point(data = eq_points, aes(x = q, y = p), color = okabe_ito[3],
             size = 5, shape = 18) +
  annotate("text", x = 0.5, y = 0.95, label = "Row player BR", color = okabe_ito[5],
           size = 3.5, fontface = "bold") +
  annotate("text", x = 0.95, y = 0.5, label = "Col player BR", color = okabe_ito[6],
           size = 3.5, fontface = "bold", angle = 90) +
  labs(
    title = "Best-response correspondences — Battle of the Sexes",
    subtitle = "Intersections (green diamonds) are Nash equilibria: 2 pure + 1 mixed",
    x = "q = P(Column plays Left)", y = "p = P(Row plays Top)"
  ) +
  theme_publication()
Figure 1: Figure 1. Best-response correspondences for Battle of the Sexes. Blue step function: Row player’s BR as a function of q (Col’s mixing probability). Red step function: Col’s BR as a function of p. Green diamonds mark Nash equilibria — two pure (corners) and one mixed (interior). The best-response plot is the core visual output of the Shiny app. Okabe-Ito palette.

Interactive expected payoff surface

A <- A_bos; B <- B_bos

grid <- expand.grid(p = seq(0, 1, by = 0.02), q = seq(0, 1, by = 0.02))
grid$row_payoff <- with(grid,
  p * (q * A[1,1] + (1-q) * A[1,2]) + (1-p) * (q * A[2,1] + (1-q) * A[2,2])
)
grid$col_payoff <- with(grid,
  p * (q * B[1,1] + (1-q) * B[1,2]) + (1-p) * (q * B[2,1] + (1-q) * B[2,2])
)

grid_text <- grid |>
  mutate(text = paste0("p=", round(p, 2), " q=", round(q, 2),
                       "\nRow payoff: ", round(row_payoff, 2),
                       "\nCol payoff: ", round(col_payoff, 2)))

p_surface <- ggplot(grid_text, aes(x = q, y = p, fill = row_payoff, text = text)) +
  geom_tile() +
  scale_fill_gradient2(low = okabe_ito[6], mid = okabe_ito[4], high = okabe_ito[3],
                        midpoint = mean(grid$row_payoff), name = "Row payoff") +
  # Mark equilibria
  geom_point(data = eq_points, aes(x = q, y = p, fill = NULL, text = label),
             color = "black", size = 3, shape = 4, stroke = 1.5) +
  labs(
    title = "Row player expected payoff — Battle of the Sexes",
    x = "q = P(Col plays Left)", y = "p = P(Row plays Top)"
  ) +
  theme_publication() +
  theme(panel.grid = element_blank())

ggplotly(p_surface, tooltip = "text") |>
  config(displaylogo = FALSE,
         modeBarButtonsToRemove = c("select2d", "lasso2d"))
Figure 2

Shiny app architecture

The complete app (shiny-apps/01-two-by-two-nash-explorer/app.R) has four main components:

  1. Payoff input panel: Eight numericInput fields arranged as a 2×2 bimatrix table, plus a selectInput for preset games (PD, BoS, Matching Pennies, Stag Hunt, Chicken).

  2. Nash solver (solve_nash_2x2): The same function shown above, wrapped in a reactive() expression that recomputes whenever any payoff input changes.

  3. Best-response plot: A plotlyOutput rendering the step-function best-response correspondences for both players, with equilibrium points marked.

  4. Expected payoff contour plots: Two side-by-side plotlyOutput panels showing the expected payoff surface for each player as a function of the mixing probabilities \((p, q)\).

The app is fully self-contained — no external data dependencies — and can be deployed to shinyapps.io or run locally with shiny::runApp("shiny-apps/01-two-by-two-nash-explorer/").

Interpretation

The 2×2 Nash Explorer makes visible the geometry of strategic interaction. The best-response plot reveals why different game structures produce different equilibrium configurations: in the Prisoner’s Dilemma, both BRs point to (Defect, Defect) regardless of the opponent’s mix — the unique NE is a dominant-strategy equilibrium. In Battle of the Sexes, the BRs create an S-shaped intersection pattern producing two pure NE at the corners and one mixed NE in the interior. In Matching Pennies, the BRs are step functions that cross only at the centre — the unique NE is completely mixed at (1/2, 1/2). The expected payoff surfaces show that at a mixed NE, neither player can unilaterally improve their expected payoff — the mixed NE sits at a saddle point of the individual payoff landscapes, not a maximum. This geometric insight — that mixed NE are not outcomes players actively seek but rather fixed points of the best-response dynamics — is fundamental to interpreting mixed equilibria correctly and connects to Nash (1950)’s original existence proof via Kakutani’s fixed-point theorem.

References

Nash, John F. 1950. “Equilibrium Points in n-Person Games.” Proceedings of the National Academy of Sciences 36 (1): 48–49. https://doi.org/10.1073/pnas.36.1.48.
Back to top

Reuse

Citation

BibTeX citation:
@online{heller2026,
  author = {Heller, Raban},
  title = {Building the 2×2 {Nash} {Equilibrium} {Explorer} — a {Shiny}
    Tutorial},
  date = {2026-05-08},
  url = {https://r-heller.github.io/equilibria/shiny/tutorials/two-by-two-nash-explorer-tutorial/},
  langid = {en}
}
For attribution, please cite this work as:
Heller, Raban. 2026. “Building the 2×2 Nash Equilibrium Explorer — a Shiny Tutorial.” May 8. https://r-heller.github.io/equilibria/shiny/tutorials/two-by-two-nash-explorer-tutorial/.