7
Iterated Prisoner's Dilemma Tournament
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.