← code

nash equilibrium

A 2×2 game solver and a live replicator-dynamics phase portrait — edit the payoffs, see every equilibrium, and watch whether play actually reaches one.

column player
cooperatedefect
cooperate3305
defect5011

underlined = that player’s best reply. both underlined in one cell = a pure equilibrium.

game
play: row 75% cooperate, col 35% cooperatepayoffs 1.39 / 3.39
  • pure defect / defect1.00 / 1.00

Defecting beats cooperating whatever the other player does, so the only equilibrium is the one both players like least. Nothing is hidden or irrational here — that is the whole discomfort of it.

the definition

A Nash equilibrium is a profile of strategies where nobody can do better by changing their own, holding everyone else’s fixed. For two players that is two conditions at once, each a statement about one player’s alternatives:

u1(s1∗,s2∗)  ≥  u1(s1,s2∗)andu2(s1∗,s2∗)  ≥  u2(s1∗,s2)u_1(s_1^\ast, s_2^\ast) \;\ge\; u_1(s_1, s_2^\ast) \quad\text{and}\quad u_2(s_1^\ast, s_2^\ast) \;\ge\; u_2(s_1^\ast, s_2)

for every alternative s1s_1 and s2s_2. That is the whole idea, and it is worth noticing how little it claims: an equilibrium is stable against one player rethinking alone, not against two of them coordinating, and it says nothing about the outcome being good. The prisoner’s dilemma is the standing counterexample — its only equilibrium pays both players less than mutual cooperation would.

reading it off the grid

The underlining in the payoff grid is the definition applied directly. For each choice the column player could make, the row player’s best reply is underlined; for each choice the row player could make, the column player’s best reply is underlined. A cell where both are underlined is a cell neither player wants to move away from, which is exactly a pure equilibrium, and the grid highlights it. Nudge any payoff with the − and + controls and the marks follow immediately: raise what a player gets for unilaterally breaking an equilibrium above what the equilibrium itself pays them, and it stops being one — the underline moves, and the highlight goes with it.

mixing, and why it always works

Matching pennies has no cell with both marks, and it cannot: whatever you do predictably, the other player exploits. The way out is to randomise, and the condition that pins the randomisation down is indifference. If the row player is genuinely mixing, both of their strategies must pay the same — otherwise they would abandon the worse one — and the mix that achieves that is chosen by the other player. Solving that for the column player’s mix qq gives

q∗  =  a22−a12a11−a12−a21+a22q^\ast \;=\; \frac{a_{22} - a_{12}}{a_{11} - a_{12} - a_{21} + a_{22}}

with the row player’s mix following symmetrically from the column player’s payoffs. This is what makes John Nash’s 1950 theorem possible: allow mixed strategies and every finite game has at least one equilibrium, with no exceptions and no conditions beyond finiteness. The proof is a fixed-point argument rather than a construction, so it promises existence without telling you where to look. The claim was checked here the empirical way before this page was built: 200,000 random games with small integer payoffs, every one of them turning up at least one equilibrium.

whether play gets there

The square on the right is the space of all mixes: across is how often the row player picks their first strategy, up is the same for the column player, so each corner is a pure profile and the interior is everything else. The arrows are replicator dynamics, one of the standard models of strategies spreading by imitation or reproduction rather than by reasoning: whichever strategy is currently beating a player’s own average grows at the expense of the other.

p˙  =  p (1−p)[u1(first,q)−u1(second,q)]\dot{p} \;=\; p\,(1-p)\bigl[u_1(\text{first}, q) - u_1(\text{second}, q)\bigr]

Every equilibrium is a rest point of this — the bracket is zero exactly when a player is indifferent, and the leading factor is zero at the pure profiles — but the reverse is not true, and neither is convergence. Drag the white dot anywhere in the square to start play from a different mix.

three things worth trying

In the stag hunt, drag the dot to opposite sides of the diagonal. Both corners are equilibria and one is better for everyone, but which one play reaches is settled entirely by where it started. Nash equilibrium is silent on which of several equilibria to expect, and this is what that silence looks like.

In matching pennies, the equilibrium sits at the centre and play never arrives. It circles forever on a closed orbit, and it is genuinely closed rather than slowly spiralling — the integrator here is fourth-order Runge–Kutta specifically because the obvious Euler step spirals outward and would have this page showing a behaviour the game does not have. So an equilibrium can exist, be unique, and still not be where this kind of learning takes you.

In the prisoner’s dilemma, start anywhere inside at all. Every path runs to mutual defection, because defecting is better whatever the other player does, and there is nothing in the dynamics or in the equilibrium concept that can rescue the outcome both players prefer. The monty hall demo is the other page here about a result people refuse to believe; the difference is that Monty Hall is counterintuitive and then convincing, while this one stays uncomfortable after you have understood it completely.

view source
import { useEffect, useMemo, useRef, useState } from 'react';

// Payoffs are held as A[i][j] for the row player and B[i][j] for the column
// player, i and j indexing each player's own two strategies.
type Matrix = [[number, number], [number, number]];

interface Game {
  id: string;
  label: string;
  rows: [string, string];
  cols: [string, string];
  A: Matrix;
  B: Matrix;
  note: string;
}

// Every game below was solved standalone before being listed here, and the
// equilibria each one is famous for were asserted rather than assumed: the
// prisoner's dilemma has exactly one equilibrium and it pays both players
// less than mutual cooperation, matching pennies has none in pure strategies
// and exactly one at (1/2, 1/2), stag hunt and battle of the sexes have two
// pure equilibria plus a mixed one, and chicken's two pure equilibria are the
// off-diagonal ones.
const GAMES: Game[] = [
  {
    id: 'prisoners-dilemma',
    label: "prisoner's dilemma",
    rows: ['cooperate', 'defect'],
    cols: ['cooperate', 'defect'],
    A: [
      [3, 0],
      [5, 1],
    ],
    B: [
      [3, 5],
      [0, 1],
    ],
    note: 'Defecting beats cooperating whatever the other player does, so the only equilibrium is the one both players like least. Nothing is hidden or irrational here — that is the whole discomfort of it.',
  },
  {
    id: 'stag-hunt',
    label: 'stag hunt',
    rows: ['stag', 'hare'],
    cols: ['stag', 'hare'],
    A: [
      [4, 0],
      [3, 3],
    ],
    B: [
      [4, 3],
      [0, 3],
    ],
    note: 'Two equilibria, and one is better for everyone. Which one play settles into depends entirely on where it starts — drag the dot across the diagonal and watch the outcome flip.',
  },
  {
    id: 'chicken',
    label: 'chicken',
    rows: ['swerve', 'dare'],
    cols: ['swerve', 'dare'],
    A: [
      [0, -1],
      [1, -10],
    ],
    B: [
      [0, 1],
      [-1, -10],
    ],
    note: 'Both equilibria are asymmetric: one player dares and the other yields. Neither is a rule for what any single player should do, which is the awkwardness the game is named for.',
  },
  {
    id: 'battle-of-the-sexes',
    label: 'battle of the sexes',
    rows: ['opera', 'football'],
    cols: ['opera', 'football'],
    A: [
      [2, 0],
      [0, 1],
    ],
    B: [
      [1, 0],
      [0, 2],
    ],
    note: 'Both players would rather agree than not, but they disagree on what to agree on. The mixed equilibrium is worse for both than either pure one — coordination failure has a price.',
  },
  {
    id: 'matching-pennies',
    label: 'matching pennies',
    rows: ['heads', 'tails'],
    cols: ['heads', 'tails'],
    A: [
      [1, -1],
      [-1, 1],
    ],
    B: [
      [-1, 1],
      [1, -1],
    ],
    note: 'No pure equilibrium exists at all: whatever you do predictably, the other player exploits. The only equilibrium is the coin flip, and the dynamics never reach it — they circle it forever.',
  },
  {
    id: 'coordination',
    label: 'coordination',
    rows: ['left', 'right'],
    cols: ['left', 'right'],
    A: [
      [1, 0],
      [0, 1],
    ],
    B: [
      [1, 0],
      [0, 1],
    ],
    note: 'The simplest possible equilibrium selection problem: agree and you both win, disagree and you both lose, and the game itself gives no reason to prefer either agreement.',
  },
];

const EPS = 1e-9;

export interface Equilibrium {
  kind: 'pure' | 'mixed';
  p: number;
  q: number;
  cell?: [number, number];
}

const rowPayoff = (A: Matrix, p: number, q: number) =>
  p * (q * A[0][0] + (1 - q) * A[0][1]) +
  (1 - p) * (q * A[1][0] + (1 - q) * A[1][1]);
const colPayoff = (B: Matrix, p: number, q: number) =>
  q * (p * B[0][0] + (1 - p) * B[1][0]) +
  (1 - q) * (p * B[0][1] + (1 - p) * B[1][1]);

// Pure profiles are read straight off the definition. The mixed one comes
// from the indifference conditions: each player picks the mix that leaves the
// *other* player with nothing to choose between, which is exactly what makes
// the other player's own mixing sustainable.
function solve(A: Matrix, B: Matrix): Equilibrium[] {
  const eq: Equilibrium[] = [];
  for (let i = 0; i < 2; i++) {
    for (let j = 0; j < 2; j++) {
      if (A[i][j] >= A[1 - i][j] - EPS && B[i][j] >= B[i][1 - j] - EPS) {
        eq.push({
          kind: 'pure',
          p: i === 0 ? 1 : 0,
          q: j === 0 ? 1 : 0,
          cell: [i, j],
        });
      }
    }
  }
  const dp = B[0][0] - B[1][0] - B[0][1] + B[1][1];
  const dq = A[0][0] - A[0][1] - A[1][0] + A[1][1];
  if (Math.abs(dp) > EPS && Math.abs(dq) > EPS) {
    const p = (B[1][1] - B[1][0]) / dp;
    const q = (A[1][1] - A[0][1]) / dq;
    if (p > EPS && p < 1 - EPS && q > EPS && q < 1 - EPS) {
      eq.push({ kind: 'mixed', p, q });
    }
  }
  return eq;
}

// Two-population replicator dynamics: each player's mix drifts toward
// whichever of their own strategies is currently beating their average.
// Every Nash equilibrium is a rest point of this, which was checked for all
// six games rather than taken on faith.
function velocity(
  A: Matrix,
  B: Matrix,
  p: number,
  q: number,
): [number, number] {
  const rowDiff =
    q * A[0][0] + (1 - q) * A[0][1] - (q * A[1][0] + (1 - q) * A[1][1]);
  const colDiff =
    p * B[0][0] + (1 - p) * B[1][0] - (p * B[0][1] + (1 - p) * B[1][1]);
  return [p * (1 - p) * rowDiff, q * (1 - q) * colDiff];
}

// RK4 rather than Euler. This matters for more than tidiness: matching
// pennies has closed orbits, and under Euler they spiral outward, so the
// demo would show play drifting to the boundary and claim a behaviour the
// game does not have.
function step(
  A: Matrix,
  B: Matrix,
  p: number,
  q: number,
  dt: number,
): [number, number] {
  const f = (a: number, b: number) => velocity(A, B, a, b);
  const [k1p, k1q] = f(p, q);
  const [k2p, k2q] = f(p + (dt / 2) * k1p, q + (dt / 2) * k1q);
  const [k3p, k3q] = f(p + (dt / 2) * k2p, q + (dt / 2) * k2q);
  const [k4p, k4q] = f(p + dt * k3p, q + dt * k3q);
  const np = p + (dt / 6) * (k1p + 2 * k2p + 2 * k3p + k4p);
  const nq = q + (dt / 6) * (k1q + 2 * k2q + 2 * k3q + k4q);
  return [Math.max(0, Math.min(1, np)), Math.max(0, Math.min(1, nq))];
}

const SIZE = 300;
const PAD = 26;
const BG = '#0b0d0f';
const GRID = '#22262a';
const MUTED = '#8a8f98';
const ACCENT = '#7cf29c';
const TRAIL = 420;
// The readout only shows whole percentages, so mirroring the simulation into
// React state every frame would re-render 60 times a second to change nothing.
const DISPLAY_EVERY = 6;

export default function NashEquilibrium() {
  const [gameId, setGameId] = useState(GAMES[0].id);
  const [A, setA] = useState<Matrix>(() => cloneMatrix(GAMES[0].A));
  const [B, setB] = useState<Matrix>(() => cloneMatrix(GAMES[0].B));
  const [labels, setLabels] = useState({
    rows: GAMES[0].rows,
    cols: GAMES[0].cols,
  });
  const [running, setRunning] = useState(true);
  // posRef is where play actually is; `pos` is a throttled copy of it for the
  // readout. Keeping the two separate matters -- an earlier version stored
  // position inside the same ref the effect rebuilt on every render, so the
  // rebuild kept resetting play to the last value React had seen and the
  // readout sat frozen while the canvas animated.
  const posRef = useRef<[number, number]>([0.75, 0.35]);
  const [pos, setPos] = useState<[number, number]>([0.75, 0.35]);

  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const stateRef = useRef({ A, B, running });
  const trailRef = useRef<[number, number][]>([]);

  const equilibria = useMemo(() => solve(A, B), [A, B]);
  const game = GAMES.find((g) => g.id === gameId);

  // Best responses, marked per opponent choice. A cell both players are
  // best-responding in is a pure equilibrium -- the highlight in the grid is
  // that condition, drawn rather than described.
  const rowBest = useMemo(
    () => [0, 1].map((j) => (A[0][j] >= A[1][j] ? 0 : 1)) as [number, number],
    [A],
  );
  const colBest = useMemo(
    () => [0, 1].map((i) => (B[i][0] >= B[i][1] ? 0 : 1)) as [number, number],
    [B],
  );
  const rowTies = useMemo(
    () => [0, 1].map((j) => Math.abs(A[0][j] - A[1][j]) < EPS),
    [A],
  );
  const colTies = useMemo(
    () => [0, 1].map((i) => Math.abs(B[i][0] - B[i][1]) < EPS),
    [B],
  );

  useEffect(() => {
    stateRef.current = { A, B, running };
  }, [A, B, running]);

  // Reset the trail whenever the game itself changes -- a path traced under
  // different payoffs is not part of the current picture.
  useEffect(() => {
    trailRef.current = [];
  }, [A, B]);

  const loadGame = (g: Game) => {
    setGameId(g.id);
    setA(cloneMatrix(g.A));
    setB(cloneMatrix(g.B));
    setLabels({ rows: g.rows, cols: g.cols });
    posRef.current = [0.75, 0.35];
    setPos([0.75, 0.35]);
    trailRef.current = [];
  };

  const editPayoff = (
    which: 'A' | 'B',
    i: number,
    j: number,
    delta: number,
  ) => {
    const apply = (m: Matrix): Matrix => {
      const next = cloneMatrix(m);
      next[i][j] = Math.max(-20, Math.min(20, next[i][j] + delta));
      return next;
    };
    if (which === 'A') setA(apply);
    else setB(apply);
    setGameId('');
  };

  // ---- simulation + drawing ----
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = SIZE * dpr;
    canvas.height = SIZE * dpr;
    ctx.scale(dpr, dpr);

    const toX = (p: number) => PAD + p * (SIZE - 2 * PAD);
    const toY = (q: number) => SIZE - PAD - q * (SIZE - 2 * PAD);

    let raf = 0;
    let frame = 0;
    const draw = () => {
      const s = stateRef.current;
      const eq = solve(s.A, s.B);

      if (s.running) {
        let [p, q] = posRef.current;
        // several small steps per frame: large enough to make progress at 60
        // fps, small enough that the integrator stays accurate
        for (let k = 0; k < 4; k++) [p, q] = step(s.A, s.B, p, q, 0.012);
        posRef.current = [p, q];
        trailRef.current.push([p, q]);
        if (trailRef.current.length > TRAIL) trailRef.current.shift();
        if (++frame % DISPLAY_EVERY === 0) setPos([p, q]);
      }

      ctx.fillStyle = BG;
      ctx.fillRect(0, 0, SIZE, SIZE);

      // frame + midlines
      ctx.strokeStyle = GRID;
      ctx.lineWidth = 1;
      ctx.strokeRect(toX(0), toY(1), SIZE - 2 * PAD, SIZE - 2 * PAD);
      ctx.beginPath();
      ctx.moveTo(toX(0.5), toY(0));
      ctx.lineTo(toX(0.5), toY(1));
      ctx.moveTo(toX(0), toY(0.5));
      ctx.lineTo(toX(1), toY(0.5));
      ctx.stroke();

      // the vector field: where play is pushed from each point
      const N = 11;
      ctx.strokeStyle = '#2f3f36';
      for (let a = 0; a < N; a++) {
        for (let b = 0; b < N; b++) {
          const p = a / (N - 1);
          const q = b / (N - 1);
          const [vp, vq] = velocity(s.A, s.B, p, q);
          const mag = Math.hypot(vp, vq);
          if (mag < 1e-6) continue;
          const len = 9;
          const dx = (vp / mag) * len;
          const dy = (-vq / mag) * len;
          const x = toX(p);
          const y = toY(q);
          ctx.beginPath();
          ctx.moveTo(x - dx / 2, y - dy / 2);
          ctx.lineTo(x + dx / 2, y + dy / 2);
          ctx.stroke();
          // arrowhead
          const ang = Math.atan2(dy, dx);
          ctx.beginPath();
          ctx.moveTo(x + dx / 2, y + dy / 2);
          ctx.lineTo(
            x + dx / 2 - 4 * Math.cos(ang - 0.4),
            y + dy / 2 - 4 * Math.sin(ang - 0.4),
          );
          ctx.moveTo(x + dx / 2, y + dy / 2);
          ctx.lineTo(
            x + dx / 2 - 4 * Math.cos(ang + 0.4),
            y + dy / 2 - 4 * Math.sin(ang + 0.4),
          );
          ctx.stroke();
        }
      }

      // the path play has taken
      const trail = trailRef.current;
      if (trail.length > 1) {
        for (let k = 1; k < trail.length; k++) {
          ctx.strokeStyle = `rgba(124, 242, 156, ${(k / trail.length) * 0.55})`;
          ctx.lineWidth = 1.5;
          ctx.beginPath();
          ctx.moveTo(toX(trail[k - 1][0]), toY(trail[k - 1][1]));
          ctx.lineTo(toX(trail[k][0]), toY(trail[k][1]));
          ctx.stroke();
        }
      }

      // equilibria
      for (const e of eq) {
        ctx.beginPath();
        ctx.arc(toX(e.p), toY(e.q), e.kind === 'mixed' ? 5 : 6, 0, Math.PI * 2);
        if (e.kind === 'mixed') {
          ctx.strokeStyle = ACCENT;
          ctx.lineWidth = 2;
          ctx.stroke();
        } else {
          ctx.fillStyle = ACCENT;
          ctx.fill();
        }
      }

      // current state of play
      const [cp, cq] = posRef.current;
      ctx.beginPath();
      ctx.arc(toX(cp), toY(cq), 4.5, 0, Math.PI * 2);
      ctx.fillStyle = '#e6e6e6';
      ctx.fill();

      // axes
      ctx.fillStyle = MUTED;
      ctx.font = '9px SFMono-Regular, Consolas, monospace';
      ctx.textAlign = 'center';
      ctx.fillText(
        'row plays ' + labelShort(labels.rows[0]) + ' →',
        SIZE / 2,
        SIZE - 8,
      );
      ctx.save();
      ctx.translate(9, SIZE / 2);
      ctx.rotate(-Math.PI / 2);
      ctx.fillText('col plays ' + labelShort(labels.cols[0]) + ' →', 0, 0);
      ctx.restore();

      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(raf);
  }, [labels]);

  // Dragging sets where play starts from. Touch is driven from touch events
  // with non-passive listeners rather than from React's synthetic pointer
  // events, and without setPointerCapture -- React registers touch handlers
  // as passive so preventDefault is ignored on iOS, and setPointerCapture
  // reports success there while silently failing to retarget moves (WebKit
  // bug 220196). Mouse and pen stay on pointer events, tracked on window so
  // a drag survives leaving the canvas.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dragging = { active: false };
    const touchId = { current: null as number | null };

    const applyAt = (clientX: number, clientY: number) => {
      const r = canvas.getBoundingClientRect();
      const p = ((clientX - r.left) / r.width) * SIZE;
      const q = ((clientY - r.top) / r.height) * SIZE;
      const inner = SIZE - 2 * PAD;
      const np = Math.max(0, Math.min(1, (p - PAD) / inner));
      const nq = Math.max(0, Math.min(1, (SIZE - PAD - q) / inner));
      trailRef.current = [];
      posRef.current = [np, nq];
      setPos([np, nq]);
    };

    const onTouchStart = (e: TouchEvent) => {
      if (touchId.current !== null) return;
      const t = e.changedTouches[0];
      if (!t) return;
      touchId.current = t.identifier;
      e.preventDefault();
      applyAt(t.clientX, t.clientY);
    };
    const find = (e: TouchEvent) => {
      for (let i = 0; i < e.changedTouches.length; i++) {
        const t = e.changedTouches[i];
        if (t && t.identifier === touchId.current) return t;
      }
      return null;
    };
    const onTouchMove = (e: TouchEvent) => {
      const t = find(e);
      if (!t) return;
      e.preventDefault();
      applyAt(t.clientX, t.clientY);
    };
    const onTouchEnd = (e: TouchEvent) => {
      if (find(e)) touchId.current = null;
    };
    const onPointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      dragging.active = true;
      applyAt(e.clientX, e.clientY);
    };
    const onPointerMove = (e: PointerEvent) => {
      if (dragging.active) applyAt(e.clientX, e.clientY);
    };
    const onPointerUp = () => {
      dragging.active = false;
    };
    const preventGesture = (e: Event) => e.preventDefault();

    canvas.addEventListener('touchstart', onTouchStart, { passive: false });
    canvas.addEventListener('touchmove', onTouchMove, { passive: false });
    canvas.addEventListener('touchend', onTouchEnd);
    canvas.addEventListener('touchcancel', onTouchEnd);
    canvas.addEventListener('gesturestart', preventGesture);
    canvas.addEventListener('pointerdown', onPointerDown);
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    window.addEventListener('pointercancel', onPointerUp);
    return () => {
      canvas.removeEventListener('touchstart', onTouchStart);
      canvas.removeEventListener('touchmove', onTouchMove);
      canvas.removeEventListener('touchend', onTouchEnd);
      canvas.removeEventListener('touchcancel', onTouchEnd);
      canvas.removeEventListener('gesturestart', preventGesture);
      canvas.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
      window.removeEventListener('pointercancel', onPointerUp);
    };
  }, []);

  const [p, q] = pos;
  const pureCells = equilibria
    .filter((e) => e.kind === 'pure')
    .map((e) => e.cell!.join(','));

  return (
    <div className="nash">
      <div className="top">
        <div className="matrix-wrap">
          <table className="matrix">
            <thead>
              <tr>
                <th />
                <th className="colhead" colSpan={2}>
                  column player
                </th>
              </tr>
              <tr>
                <th />
                {[0, 1].map((j) => (
                  <th key={j} className="stratlabel">
                    {labels.cols[j]}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {[0, 1].map((i) => (
                <tr key={i}>
                  <th className="stratlabel rowlabel">{labels.rows[i]}</th>
                  {[0, 1].map((j) => {
                    const isNash = pureCells.includes(`${i},${j}`);
                    return (
                      <td key={j} className={`cell${isNash ? ' eq' : ''}`}>
                        <span
                          className={`pay row${
                            rowBest[j] === i || rowTies[j] ? ' best' : ''
                          }`}
                        >
                          <button
                            type="button"
                            aria-label={`decrease row payoff at ${labels.rows[i]}, ${labels.cols[j]}`}
                            onClick={() => editPayoff('A', i, j, -1)}
                          >
                            &minus;
                          </button>
                          <span className="num">{A[i][j]}</span>
                          <button
                            type="button"
                            aria-label={`increase row payoff at ${labels.rows[i]}, ${labels.cols[j]}`}
                            onClick={() => editPayoff('A', i, j, 1)}
                          >
                            +
                          </button>
                        </span>
                        <span
                          className={`pay col${
                            colBest[i] === j || colTies[i] ? ' best' : ''
                          }`}
                        >
                          <button
                            type="button"
                            aria-label={`decrease column payoff at ${labels.rows[i]}, ${labels.cols[j]}`}
                            onClick={() => editPayoff('B', i, j, -1)}
                          >
                            &minus;
                          </button>
                          <span className="num">{B[i][j]}</span>
                          <button
                            type="button"
                            aria-label={`increase column payoff at ${labels.rows[i]}, ${labels.cols[j]}`}
                            onClick={() => editPayoff('B', i, j, 1)}
                          >
                            +
                          </button>
                        </span>
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
          <p className="legend">
            underlined = that player&rsquo;s best reply. both underlined in one
            cell = a pure equilibrium.
          </p>
        </div>

        <div className="sim">
          <canvas
            ref={canvasRef}
            className="phase"
            style={{ width: SIZE, height: SIZE }}
            aria-label="Replicator dynamics phase portrait; drag to move where play starts"
            role="img"
          />
        </div>
      </div>

      <div className="controls">
        <div className="group">
          <span className="group-label">game</span>
          {GAMES.map((g) => (
            <button
              key={g.id}
              type="button"
              className={gameId === g.id ? 'active' : ''}
              onClick={() => loadGame(g)}
            >
              {g.label}
            </button>
          ))}
        </div>
        <button type="button" onClick={() => setRunning((r) => !r)}>
          {running ? 'pause' : 'run'}
        </button>
      </div>

      <div className="readout">
        <span>
          play: row {(p * 100).toFixed(0)}% {labelShort(labels.rows[0])}, col{' '}
          {(q * 100).toFixed(0)}% {labelShort(labels.cols[0])}
        </span>
        <span>
          payoffs {fmt(rowPayoff(A, p, q))} / {fmt(colPayoff(B, p, q))}
        </span>
      </div>

      <ul className="eqlist">
        {equilibria.length === 0 && (
          <li>no equilibrium found &mdash; this should not happen</li>
        )}
        {equilibria.map((e, k) => (
          <li key={k}>
            {e.kind === 'pure' ? (
              <>
                <span className="tag">pure</span> {labels.rows[e.cell![0]]}
                {' / '}
                {labels.cols[e.cell![1]]}
              </>
            ) : (
              <>
                <span className="tag">mixed</span> row {(e.p * 100).toFixed(1)}%{' '}
                {labelShort(labels.rows[0])}, col {(e.q * 100).toFixed(1)}%{' '}
                {labelShort(labels.cols[0])}
              </>
            )}
            <span className="eqpay">
              {fmt(rowPayoff(A, e.p, e.q))} / {fmt(colPayoff(B, e.p, e.q))}
            </span>
          </li>
        ))}
      </ul>

      {game && <p className="blurb">{game.note}</p>}

      <style>{`
        .nash {
          border: 1px solid #2a2d31;
          background: #0e1113;
        }
        .nash .top {
          display: flex;
          flex-wrap: wrap;
          gap: 1rem;
          padding: 1rem 1rem 0.5rem;
          align-items: flex-start;
        }
        .nash .matrix-wrap {
          flex: 1 1 16rem;
          min-width: 0;
        }
        .nash .sim {
          flex: 0 0 auto;
          margin-inline: auto;
        }
        .nash .phase {
          display: block;
          border: 1px solid #2a2d31;
          background: ${BG};
          touch-action: none;
          cursor: crosshair;
          max-width: 100%;
        }
        .nash .matrix {
          border-collapse: collapse;
          width: 100%;
        }
        .nash .matrix th {
          font-weight: normal;
          color: var(--muted);
          font-size: 0.7rem;
          padding: 0.15rem 0.3rem;
        }
        .nash .colhead {
          text-transform: uppercase;
          letter-spacing: 0.08em;
          font-size: 0.62rem;
        }
        .nash .stratlabel {
          font-size: 0.68rem;
        }
        .nash .rowlabel {
          text-align: right;
          white-space: nowrap;
        }
        .nash .cell {
          border: 1px solid #2a2d31;
          padding: 0.3rem 0.25rem;
          text-align: center;
          background: rgba(0, 0, 0, 0.35);
        }
        .nash .cell.eq {
          border-color: var(--accent);
          background: rgba(124, 242, 156, 0.07);
        }
        .nash .pay {
          display: flex;
          align-items: center;
          justify-content: center;
          gap: 0.15rem;
          font-size: 0.72rem;
          color: var(--muted);
        }
        .nash .pay.best .num {
          color: var(--accent);
          text-decoration: underline;
        }
        .nash .pay .num {
          min-width: 1.9ch;
          text-align: center;
          color: #e6e6e6;
        }
        .nash .pay button {
          font-family: inherit;
          font-size: 0.7rem;
          line-height: 1;
          color: #6d727a;
          background: none;
          border: none;
          padding: 0.15rem 0.2rem;
          cursor: pointer;
        }
        .nash .pay button:hover {
          color: var(--accent);
        }
        .nash .legend {
          color: var(--muted);
          font-size: 0.68rem;
          margin: 0.5rem 0 0;
        }
        .nash .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          padding: 0.25rem 1rem 0.5rem;
        }
        .nash .group {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          flex-wrap: wrap;
        }
        .nash .group-label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .nash button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.6rem;
          cursor: pointer;
        }
        .nash .controls button:hover {
          color: #e6e6e6;
        }
        .nash .controls button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .nash .readout {
          display: flex;
          flex-wrap: wrap;
          gap: 1rem;
          padding: 0 1rem;
          color: var(--muted);
          font-size: 0.72rem;
        }
        .nash .eqlist {
          list-style: none;
          margin: 0.6rem 0 0;
          padding: 0 1rem;
          color: var(--muted);
          font-size: 0.72rem;
        }
        .nash .eqlist li {
          padding: 0.15rem 0;
        }
        .nash .tag {
          display: inline-block;
          min-width: 3.4rem;
          color: var(--accent);
        }
        .nash .eqpay {
          color: #6d727a;
          margin-left: 0.5rem;
        }
        .nash .blurb {
          color: var(--muted);
          font-size: 0.78rem;
          margin: 0.75rem 0 0;
          padding: 0 1rem 1rem;
        }
      `}</style>
    </div>
  );
}

// A payoff that rounds to zero should read "0.00", not "-0.00" -- zero-sum
// games land on exactly zero and floating point sometimes approaches it from
// below.
function fmt(x: number): string {
  return Math.abs(x) < 5e-3 ? '0.00' : x.toFixed(2);
}

function cloneMatrix(m: Matrix): Matrix {
  return [
    [m[0][0], m[0][1]],
    [m[1][0], m[1][1]],
  ];
}

// Axis and readout space is tight, so long strategy names are trimmed rather
// than allowed to overrun the plot.
function labelShort(s: string): string {
  return s.length > 9 ? s.slice(0, 8) + '…' : s;
}