7

Iterated Prisoner's Dilemma Tournament

An Axelrod-style round-robin tournament of the iterated prisoner's dilemma. Six classic strategies — ALL_C, ALL_D, TIT_FOR_TAT, GRIM, RANDOM, and PAVLOV (win-stay, lose-shift) — face each other for 200 rounds per pairing using the canonical payoff matrix: mutual cooperation , sucker , temptation , mutual defection . The left panel scrolls the per-round moves of the current match (green = cooperate, red = defect); the right panel tracks cumulative score across all matchups. When Robert Axelrod ran the first such tournament in 1980, the surprise winner was the absurdly simple TIT_FOR_TAT — cooperate on round one, then copy your opponent's last move. It scored highest despite never beating any opponent head-to-head, because being 'nice, retaliatory, forgiving, and clear' extracts mutual cooperation from anyone willing to give it. In noisy environments, where moves occasionally flip by mistake, PAVLOV tends to dominate instead: it forgives accidental defections faster than TIT_FOR_TAT, which can lock into endless mutual-defection echo chambers after a single slip.

idle
292 lines · vanilla
view source
// Axelrod-style iterated prisoner's dilemma tournament.
// 6 strategies play round-robin, 200 rounds per matchup.
// Payoffs: CC=3, CD=0, DC=5, DD=1.

const ROUNDS = 200;
const PAYOFF = {
  // [my action][opp action] => my payoff
  C: { C: 3, D: 0 },
  D: { C: 5, D: 1 },
};

// Strategy table. Each strategy is a function (history, oppHistory, state) => 'C' | 'D'.
const STRATS = [
  {
    name: 'ALL_C',
    color: '#7fe3a2',
    init: () => ({}),
    act: () => 'C',
  },
  {
    name: 'ALL_D',
    color: '#ff6b6b',
    init: () => ({}),
    act: () => 'D',
  },
  {
    name: 'TIT_FOR_TAT',
    color: '#7ad7ff',
    init: () => ({}),
    act: (my, opp) => (opp.length === 0 ? 'C' : opp[opp.length - 1]),
  },
  {
    name: 'GRIM',
    color: '#c779ff',
    init: () => ({ triggered: false }),
    act: (my, opp, s) => {
      if (s.triggered) return 'D';
      if (opp.length > 0 && opp[opp.length - 1] === 'D') {
        s.triggered = true;
        return 'D';
      }
      return 'C';
    },
  },
  {
    name: 'RANDOM',
    color: '#ffd66b',
    init: () => ({}),
    act: () => (Math.random() < 0.5 ? 'C' : 'D'),
  },
  {
    name: 'PAVLOV',
    // Win-stay, lose-shift. Cooperate first.
    // "Win" = previous payoff in {3,5} (CC or DC). "Lose" = previous payoff in {0,1}.
    color: '#ff9d4a',
    init: () => ({ last: 'C', lastPayoff: 3 }),
    act: (my, opp, s) => {
      if (my.length === 0) return 'C';
      const won = s.lastPayoff >= 3;
      return won ? s.last : s.last === 'C' ? 'D' : 'C';
    },
  },
];

const N = STRATS.length;

// Build the round-robin schedule (i < j), include self-play? Classic Axelrod
// included self-play. We'll exclude self-play to keep the visualization tight.
function buildSchedule() {
  const s = [];
  for (let i = 0; i < N; i++) {
    for (let j = i + 1; j < N; j++) {
      s.push([i, j]);
    }
  }
  return s;
}

let W, H;
let schedule;
let matchIdx;
let round;
let stratStates; // per match
let histA, histB; // arrays of 'C'/'D'
let cumScore; // Float64Array length N
let matchHistory; // length ROUNDS, each {a:'C'|'D', b:'C'|'D'}
let phase; // 'play' | 'finished'
let finishedTimer;
let roundsPerFrame;

function freshMatch(i, j) {
  stratStates = [STRATS[i].init(), STRATS[j].init()];
  histA = [];
  histB = [];
  matchHistory = new Array(ROUNDS);
  round = 0;
}

function stepMatch() {
  const [i, j] = schedule[matchIdx];
  const sA = STRATS[i];
  const sB = STRATS[j];
  const aMove = sA.act(histA, histB, stratStates[0]);
  const bMove = sB.act(histB, histA, stratStates[1]);
  const aPay = PAYOFF[aMove][bMove];
  const bPay = PAYOFF[bMove][aMove];
  cumScore[i] += aPay;
  cumScore[j] += bPay;
  // PAVLOV bookkeeping
  if (sA.name === 'PAVLOV') {
    stratStates[0].last = aMove;
    stratStates[0].lastPayoff = aPay;
  }
  if (sB.name === 'PAVLOV') {
    stratStates[1].last = bMove;
    stratStates[1].lastPayoff = bPay;
  }
  histA.push(aMove);
  histB.push(bMove);
  matchHistory[round] = { a: aMove, b: bMove };
  round++;
}

function resetTournament() {
  schedule = buildSchedule();
  matchIdx = 0;
  cumScore = new Float64Array(N);
  freshMatch(schedule[0][0], schedule[0][1]);
  phase = 'play';
  finishedTimer = 0;
}

function init({ ctx, width, height }) {
  W = width;
  H = height;
  // ~5-30s total scroll. Total rounds = matches * ROUNDS.
  // matches = N*(N-1)/2 = 15 here. 15*200 = 3000 rounds. ~15s at 200 rounds/sec.
  roundsPerFrame = 3;
  resetTournament();
  ctx.fillStyle = '#0b0d12';
  ctx.fillRect(0, 0, W, H);
}

function drawBg(ctx) {
  ctx.fillStyle = '#0b0d12';
  ctx.fillRect(0, 0, W, H);
}

function drawHeader(ctx) {
  const [i, j] = schedule[matchIdx];
  const a = STRATS[i];
  const b = STRATS[j];
  ctx.fillStyle = '#e8ecf2';
  ctx.font = 'bold 14px sans-serif';
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  const matchText = `Match ${matchIdx + 1}/${schedule.length}`;
  ctx.fillText(matchText, 10, 8);

  // Centered strategy names with colors.
  ctx.textAlign = 'center';
  ctx.font = 'bold 13px sans-serif';
  const cx = W * 0.5;
  ctx.fillStyle = a.color;
  ctx.fillText(a.name, cx - 60, 8);
  ctx.fillStyle = '#9aa3b2';
  ctx.fillText('vs', cx, 8);
  ctx.fillStyle = b.color;
  ctx.fillText(b.name, cx + 60, 8);

  // Round counter right-aligned
  ctx.textAlign = 'right';
  ctx.font = '12px sans-serif';
  ctx.fillStyle = '#9aa3b2';
  ctx.fillText(`Round ${round}/${ROUNDS}`, W - 10, 10);
}

function drawLeftPanel(ctx) {
  // Left panel = current matchup grid. Two columns (A move, B move),
  // rows = rounds, scrolling vertically. Newest at top.
  const panelX = 10;
  const panelY = 32;
  const panelW = Math.max(110, W * 0.42 - 20);
  const panelH = H - panelY - 12;

  ctx.fillStyle = '#13161d';
  ctx.fillRect(panelX, panelY, panelW, panelH);
  ctx.strokeStyle = '#22262f';
  ctx.lineWidth = 1;
  ctx.strokeRect(panelX + 0.5, panelY + 0.5, panelW - 1, panelH - 1);

  // Column labels.
  const [i, j] = schedule[matchIdx];
  ctx.font = '11px sans-serif';
  ctx.textBaseline = 'top';
  ctx.textAlign = 'center';
  const colW = (panelW - 20) * 0.5;
  const cxA = panelX + 10 + colW * 0.5;
  const cxB = panelX + 10 + colW * 1.5;
  ctx.fillStyle = STRATS[i].color;
  ctx.fillText(STRATS[i].name, cxA, panelY + 4);
  ctx.fillStyle = STRATS[j].color;
  ctx.fillText(STRATS[j].name, cxB, panelY + 4);

  const gridTop = panelY + 22;
  const gridH = panelH - 30;
  // Show as many rounds as fit, newest at top.
  const cellH = Math.max(1.5, Math.min(6, gridH / Math.min(ROUNDS, 120)));
  const visibleRows = Math.min(round, Math.floor(gridH / cellH));
  const cellW = colW - 4;

  for (let r = 0; r < visibleRows; r++) {
    // Newest first: matchHistory[round - 1 - r]
    const idx = round - 1 - r;
    if (idx < 0) break;
    const h = matchHistory[idx];
    const y = gridTop + r * cellH;
    // A
    ctx.fillStyle = h.a === 'C' ? '#3aa66a' : '#c84040';
    ctx.fillRect(panelX + 10 + 2, y, cellW, Math.ceil(cellH));
    // B
    ctx.fillStyle = h.b === 'C' ? '#3aa66a' : '#c84040';
    ctx.fillRect(panelX + 10 + colW + 2, y, cellW, Math.ceil(cellH));
  }

  // Legend bottom of left panel
  ctx.font = '10px sans-serif';
  ctx.textAlign = 'left';
  ctx.textBaseline = 'middle';
  const legendY = panelY + panelH - 14;
  ctx.fillStyle = '#3aa66a';
  ctx.fillRect(panelX + 8, legendY - 4, 10, 8);
  ctx.fillStyle = '#cbd2dd';
  ctx.fillText('C', panelX + 22, legendY);
  ctx.fillStyle = '#c84040';
  ctx.fillRect(panelX + 40, legendY - 4, 10, 8);
  ctx.fillStyle = '#cbd2dd';
  ctx.fillText('D', panelX + 54, legendY);
}

function drawRightPanel(ctx) {
  // Right panel = cumulative score bar chart.
  const panelX = W * 0.42 + 4;
  const panelY = 32;
  const panelW = W - panelX - 10;
  const panelH = H - panelY - 12;

  ctx.fillStyle = '#13161d';
  ctx.fillRect(panelX, panelY, panelW, panelH);
  ctx.strokeStyle = '#22262f';
  ctx.lineWidth = 1;
  ctx.strokeRect(panelX + 0.5, panelY + 0.5, panelW - 1, panelH - 1);

  ctx.fillStyle = '#cbd2dd';
  ctx.font = 'bold 11px sans-serif';
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.fillText('Cumulative Score', panelX + 8, panelY + 6);

  // Sort indices by cumulative score descending if finished, otherwise stable order.
  let order = [];
  for (let k = 0; k < N; k++) order.push(k);
  if (phase === 'finished') {
    order.sort((a, b) => cumScore[b] - cumScore[a]);
  }

  let maxS = 1;
  for (let k = 0; k < N; k++) if (cumScore[k] > maxS) maxS = cumScore[k];

  const topY = panelY + 26;
  const botY = panelY + panelH - 14;
  const rowH = (botY - topY) / N;
  const labelW = 100;
  const barX0 = panelX + 8 + labelW;
  const barXMax = panelX + panelW - 50;
  const barSpan = barXMax - barX0;

  ctx.font = '12px sans-serif';
  ctx.textBaseline = 'middle';

  for (let r = 0; r < N; r++) {
    const k = order[r];
    const y = topY + r * rowH + rowH * 0.5;
    const barH = Math.min(rowH - 4, 16);
    const s = cumScore[k];
    const w = Math.max(0, (s / maxS) * barSpan);

    // Rank (only when finished, for clarity)
    if (phase === 'finished') {
      ctx.fillStyle = '#9aa3b2';
      ctx.textAlign = 'right';
      ctx.fillText(`${r + 1}.`, panelX + 8 + 18, y);
    }

    ctx.fillStyle = STRATS[k].color;
    ctx.textAlign = 'left';
    ctx.fillText(STRATS[k].name, panelX + 8 + (phase === 'finished' ? 22 : 0), y);

    // Bar
    ctx.fillStyle = STRATS[k].color;
    ctx.globalAlpha = 0.85;
    ctx.fillRect(barX0, y - barH * 0.5, w, barH);
    ctx.globalAlpha = 1;
    // Bar outline
    ctx.strokeStyle = '#2a2f3a';
    ctx.strokeRect(barX0 + 0.5, y - barH * 0.5 + 0.5, barSpan, barH);

    // Score number
    ctx.fillStyle = '#e8ecf2';
    ctx.textAlign = 'left';
    ctx.fillText(`${Math.round(s)}`, barX0 + w + 6, y);
  }

  if (phase === 'finished') {
    ctx.fillStyle = '#ffd66b';
    ctx.font = 'bold 12px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'bottom';
    ctx.fillText('Tournament complete', panelX + panelW * 0.5, panelY + panelH - 2);
  }
}

function tick({ ctx, dt, width, height }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
  }

  // Advance simulation.
  if (phase === 'play') {
    // Scale rounds-per-frame loosely to dt, but cap for stability.
    const steps = Math.max(1, Math.min(8, Math.round(roundsPerFrame * (dt / (1 / 60)))));
    for (let s = 0; s < steps; s++) {
      if (round >= ROUNDS) {
        // Advance to next match
        matchIdx++;
        if (matchIdx >= schedule.length) {
          phase = 'finished';
          finishedTimer = 0;
          break;
        }
        const [i, j] = schedule[matchIdx];
        freshMatch(i, j);
      }
      stepMatch();
    }
  } else {
    finishedTimer += dt;
    if (finishedTimer > 2.5) {
      resetTournament();
    }
  }

  drawBg(ctx);
  drawHeader(ctx);
  drawLeftPanel(ctx);
  drawRightPanel(ctx);
}

Comments (0)

Log in to comment.