6

Public Goods Game w/ Punishment

click to toggle punishment · drag Y for punishment cost

Twelve agents play a repeated linear public-goods game. Each round agent chooses a contribution to a common pot; the pot is multiplied by and split equally among all players. Round payoff is . The marginal return on a dollar you contribute is , so a self-interested agent contributes nothing — yet the group is collectively best off when everyone contributes the max. Agents adapt myopically toward the population mean (below-mean nudge up, above-mean nudge down) with a touch of noise. With no intervention, the asymmetric incentives drive the mean to zero: cooperation collapses. Toggle the punishment mechanism and each above-mean contributor spends a small cost to fine far-below-mean free-riders 3 units; cooperation locks in around . Drag the mouse vertically to set the cost: cheap punishment (top) sustains cooperation, expensive punishment (bottom) makes the punishers themselves bleed and the group still drifts down. This is Fehr & Gächter (2000) in miniature — costly punishment as a stabilizing equilibrium selection device, with a cost ceiling above which it stops working.

idle
289 lines · vanilla
view source
// Public Goods Game with optional costly punishment.
//
// N=12 agents. Each round agent i picks contribution c_i in [0, 20] to a
// shared pot. The pot is multiplied by m=1.6 and split equally among all N.
// Round payoff for i:
//     pi_i = (20 - c_i) + (m / N) * sum(c_j)
// Marginal return on your own dollar is m/N = 0.133, so a rational selfish
// agent contributes 0. But the group is better off when everyone contributes
// — classic free-rider / social dilemma.
//
// Adaptation rule (myopic best-response-ish):
//   - agents below population mean nudge UP next round
//   - agents above population mean nudge DOWN
//   - plus a little noise
// Without intervention, the noise + asymmetric incentives drag the mean
// to ~0 (collapse).
//
// Punishment mode (toggleable):
//   For each pair (punisher, target), if c_punisher > c_target + slack,
//   punisher pays $cost to fine target $fine. Punishers are the still-
//   cooperating agents; free-riders get hit. With cheap punishment
//   (mouseY high → low cost) cooperation locks in around c≈18. With
//   expensive punishment (mouseY low → high cost) the punishers
//   themselves bleed payoff and cooperation still collapses.
//
// Interactive:
//   - click anywhere: toggle punishment on/off
//   - mouseY (continuous): punishment-cost ratio. Top of canvas = cheap
//     ($0.3 cost / $3 fine), bottom = expensive ($2.5 cost / $3 fine).

const N = 12;
const MULT = 1.6;
const MAX_C = 20;
const FINE = 3.0;
const SLACK = 2.0; // how much below mean before you get punished

let contrib;        // Float32Array N — current contributions
let payoffs;        // Float32Array N — last-round payoffs
let cumPay;         // Float32Array N — cumulative payoffs (for color)
let history;        // mean contribution per round
let punishHistory;  // 1/0 per round (was punishment on at that round)
let punishOn;
let punishCost;     // current $ cost per fine inflicted
let round;
let stepAccum;      // sub-frame counter so we don't blow through rounds
let stepEvery;      // frames per round

function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }

function reset() {
  contrib = new Float32Array(N);
  payoffs = new Float32Array(N);
  cumPay = new Float32Array(N);
  // start with a mix: some cooperators, some free-riders, some middle.
  for (let i = 0; i < N; i++) {
    contrib[i] = Math.random() * MAX_C;
  }
  history = [];
  punishHistory = [];
  punishOn = false;
  punishCost = 1.0;
  round = 0;
  stepAccum = 0;
  stepEvery = 12; // ~5 rounds/sec at 60fps
}

function init() {
  reset();
}

function playRound() {
  // Build pot and compute baseline payoffs.
  let pot = 0;
  for (let i = 0; i < N; i++) pot += contrib[i];
  const share = (MULT * pot) / N;
  let mean = pot / N;
  for (let i = 0; i < N; i++) {
    payoffs[i] = (MAX_C - contrib[i]) + share;
  }

  // Punishment phase.
  if (punishOn) {
    // Punishers are contributors above mean; targets are agents below
    // mean by more than SLACK. Each above-mean agent fines each
    // below-mean target.
    for (let i = 0; i < N; i++) {
      if (contrib[i] <= mean) continue;
      for (let j = 0; j < N; j++) {
        if (j === i) continue;
        if (contrib[j] < mean - SLACK) {
          payoffs[i] -= punishCost;
          payoffs[j] -= FINE;
        }
      }
    }
  }

  for (let i = 0; i < N; i++) cumPay[i] += payoffs[i];

  // Adaptation: below-mean agents nudge UP, above-mean nudge DOWN.
  // But if punishment is on AND you were below mean - SLACK, the fine
  // dominates and you nudge UP hard. If punishment is off, free-riding
  // is dominant and we let above-mean drift down faster than below-mean
  // climb up (asymmetry → collapse).
  const stepUp = 1.2;
  const stepDown = punishOn ? 0.6 : 1.6;
  for (let i = 0; i < N; i++) {
    let delta;
    if (contrib[i] < mean) {
      delta = stepUp * (0.6 + Math.random() * 0.8);
    } else if (contrib[i] > mean) {
      delta = -stepDown * (0.6 + Math.random() * 0.8);
    } else {
      delta = (Math.random() - 0.5) * 0.8;
    }
    // If you were punished hard, bias upward extra.
    if (punishOn && contrib[i] < mean - SLACK) {
      delta += 1.5;
    }
    // Tiny exploration noise so we don't get stuck on flat states.
    delta += (Math.random() - 0.5) * 0.6;
    contrib[i] = clamp(contrib[i] + delta, 0, MAX_C);
  }

  // Record.
  let m = 0;
  for (let i = 0; i < N; i++) m += contrib[i];
  m /= N;
  history.push(m);
  punishHistory.push(punishOn ? 1 : 0);
  if (history.length > 300) {
    history.shift();
    punishHistory.shift();
  }
  round++;
}

function tick({ ctx, width: W, height: H, input }) {
  // bg
  ctx.fillStyle = "#0b0b12";
  ctx.fillRect(0, 0, W, H);

  // --- input ---
  if (input && typeof input.consumeClicks === "function") {
    if (input.consumeClicks() > 0) punishOn = !punishOn;
  }
  // mouseY → punishment cost. top = cheap (0.3), bottom = expensive (2.5).
  if (input && typeof input.mouseY === "number" && input.mouseY >= 0) {
    const t = clamp(input.mouseY / Math.max(1, H), 0, 1);
    punishCost = 0.3 + t * 2.2;
  }

  // --- step simulation ---
  stepAccum++;
  if (stepAccum >= stepEvery) {
    stepAccum = 0;
    playRound();
  }

  // --- layout ---
  const pad = 12;
  const titleH = 26;
  const buttonH = 36;
  const bottomBarTop = H - buttonH - pad;

  // top region: bar chart (left) and time series (right) or stacked on
  // narrow screens.
  const stacked = W < 540;
  const topH = bottomBarTop - (pad + titleH) - pad;

  let barX, barY, barW, barH, tsX, tsY, tsW, tsH;
  if (stacked) {
    const splitY = pad + titleH + (topH * 0.5) | 0;
    barX = pad;
    barY = pad + titleH;
    barW = W - pad * 2;
    barH = splitY - barY - 8;
    tsX = pad;
    tsY = splitY + 8;
    tsW = W - pad * 2;
    tsH = bottomBarTop - tsY - pad;
  } else {
    const splitX = (W * 0.52) | 0;
    barX = pad;
    barY = pad + titleH;
    barW = splitX - barX - 8;
    barH = topH;
    tsX = splitX + 8;
    tsY = pad + titleH;
    tsW = W - tsX - pad;
    tsH = topH;
  }

  // --- title ---
  ctx.fillStyle = "#ddd";
  ctx.font = "13px monospace";
  ctx.textAlign = "left";
  ctx.fillText("public goods game · N=12, m=1.6, c∈[0,20]", pad, pad + 16);

  let mean = 0;
  for (let i = 0; i < N; i++) mean += contrib[i];
  mean /= N;

  ctx.fillStyle = "#888";
  ctx.font = "11px monospace";
  ctx.textAlign = "right";
  ctx.fillText(`round ${round}   mean ${mean.toFixed(1)}`, W - pad, pad + 16);
  ctx.textAlign = "left";

  // --- bar chart of per-agent contributions ---
  // panel bg
  ctx.fillStyle = "#111119";
  ctx.fillRect(barX, barY, barW, barH);
  ctx.strokeStyle = "#222";
  ctx.strokeRect(barX + 0.5, barY + 0.5, barW - 1, barH - 1);

  // y-axis tick at MAX_C
  ctx.fillStyle = "#666";
  ctx.font = "10px monospace";
  ctx.fillText("20", barX + 4, barY + 12);
  ctx.fillText("0", barX + 4, barY + barH - 4);

  // bars
  const innerX = barX + 18;
  const innerY = barY + 8;
  const innerW = barW - 18 - 6;
  const innerH = barH - 8 - 18;
  const bw = innerW / N;
  // mean line
  const meanY = innerY + innerH - (mean / MAX_C) * innerH;
  ctx.strokeStyle = "rgba(255,210,90,0.55)";
  ctx.setLineDash([3, 3]);
  ctx.beginPath();
  ctx.moveTo(innerX, meanY);
  ctx.lineTo(innerX + innerW, meanY);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = "rgba(255,210,90,0.75)";
  ctx.font = "9px monospace";
  ctx.textAlign = "left";
  ctx.fillText("mean", innerX + 2, meanY - 2);

  for (let i = 0; i < N; i++) {
    const c = contrib[i];
    const h = (c / MAX_C) * innerH;
    const x = innerX + i * bw + 1;
    const y = innerY + innerH - h;
    // Color: green if above mean (contributor), red if well below (free-rider).
    let color;
    if (c < mean - SLACK) color = "#e25555";
    else if (c > mean + 0.5) color = "#5fcf8a";
    else color = "#9aa7c4";
    ctx.fillStyle = color;
    ctx.fillRect(x, y, Math.max(1, bw - 2), h);
    // outline if this agent would be punishing right now
    if (punishOn && c > mean) {
      ctx.strokeStyle = "rgba(255,220,140,0.55)";
      ctx.lineWidth = 1;
      ctx.strokeRect(x - 0.5, y - 0.5, Math.max(1, bw - 2) + 1, h + 1);
    }
  }
  // x-axis label
  ctx.fillStyle = "#666";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText("agents 1..12", innerX + innerW / 2, barY + barH - 4);
  ctx.textAlign = "left";

  // --- time series of mean contribution ---
  ctx.fillStyle = "#111119";
  ctx.fillRect(tsX, tsY, tsW, tsH);
  ctx.strokeStyle = "#222";
  ctx.strokeRect(tsX + 0.5, tsY + 0.5, tsW - 1, tsH - 1);

  const tsInnerX = tsX + 22;
  const tsInnerY = tsY + 10;
  const tsInnerW = tsW - 22 - 8;
  const tsInnerH = tsH - 10 - 22;

  // y-axis ticks
  ctx.fillStyle = "#666";
  ctx.font = "10px monospace";
  ctx.textAlign = "right";
  ctx.fillText("20", tsX + 18, tsInnerY + 4);
  ctx.fillText("10", tsX + 18, tsInnerY + tsInnerH / 2 + 3);
  ctx.fillText("0", tsX + 18, tsInnerY + tsInnerH + 3);
  ctx.textAlign = "left";

  // gridlines
  ctx.strokeStyle = "#1c1c26";
  ctx.beginPath();
  ctx.moveTo(tsInnerX, tsInnerY + tsInnerH / 2);
  ctx.lineTo(tsInnerX + tsInnerW, tsInnerY + tsInnerH / 2);
  ctx.stroke();

  // shade rounds where punishment was on
  if (history.length > 1) {
    const stepX = tsInnerW / Math.max(1, history.length - 1);
    let runStart = -1;
    for (let i = 0; i < history.length; i++) {
      if (punishHistory[i] === 1 && runStart < 0) runStart = i;
      const ending = (punishHistory[i] === 0 || i === history.length - 1) && runStart >= 0;
      if (ending) {
        const endI = punishHistory[i] === 1 ? i : i - 1;
        const x1 = tsInnerX + runStart * stepX;
        const x2 = tsInnerX + endI * stepX;
        ctx.fillStyle = "rgba(255,210,90,0.10)";
        ctx.fillRect(x1, tsInnerY, Math.max(1, x2 - x1), tsInnerH);
        runStart = -1;
      }
    }
  }

  // axes
  ctx.strokeStyle = "#333";
  ctx.beginPath();
  ctx.moveTo(tsInnerX, tsInnerY);
  ctx.lineTo(tsInnerX, tsInnerY + tsInnerH);
  ctx.lineTo(tsInnerX + tsInnerW, tsInnerY + tsInnerH);
  ctx.stroke();

  // line
  if (history.length >= 2) {
    ctx.strokeStyle = "#7cf";
    ctx.lineWidth = 1.8;
    ctx.beginPath();
    for (let i = 0; i < history.length; i++) {
      const x = tsInnerX + (i / Math.max(1, history.length - 1)) * tsInnerW;
      const y = tsInnerY + tsInnerH - (history[i] / MAX_C) * tsInnerH;
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // legend
  ctx.fillStyle = "#888";
  ctx.font = "10px monospace";
  ctx.fillText("mean contribution(t)", tsInnerX, tsY + tsH - 6);

  // --- bottom bar: punishment toggle + cost slider readout ---
  const bbX = pad;
  const bbY = bottomBarTop;
  const bbW = W - pad * 2;
  const bbH = buttonH;

  // button-style backdrop
  const onColor = "rgba(95,207,138,0.18)";
  const offColor = "rgba(120,120,140,0.10)";
  ctx.fillStyle = punishOn ? onColor : offColor;
  ctx.fillRect(bbX, bbY, bbW, bbH);
  ctx.strokeStyle = punishOn ? "#5fcf8a" : "#444";
  ctx.lineWidth = 1.2;
  ctx.strokeRect(bbX + 0.5, bbY + 0.5, bbW - 1, bbH - 1);

  // status indicator dot
  const dotX = bbX + 14;
  const dotY = bbY + bbH / 2;
  ctx.fillStyle = punishOn ? "#5fcf8a" : "#666";
  ctx.beginPath();
  ctx.arc(dotX, dotY, 6, 0, Math.PI * 2);
  ctx.fill();
  if (punishOn) {
    ctx.strokeStyle = "rgba(95,207,138,0.45)";
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(dotX, dotY, 10, 0, Math.PI * 2);
    ctx.stroke();
  }

  ctx.fillStyle = punishOn ? "#cfeedd" : "#bbb";
  ctx.font = "12px monospace";
  ctx.textAlign = "left";
  const label = punishOn ? "PUNISHMENT: ON" : "PUNISHMENT: OFF";
  ctx.fillText(label, dotX + 14, dotY + 4);

  // cost readout on the right
  ctx.fillStyle = "#888";
  ctx.font = "11px monospace";
  ctx.textAlign = "right";
  const ratio = (FINE / punishCost).toFixed(1);
  ctx.fillText(
    `cost $${punishCost.toFixed(2)} / fine $${FINE.toFixed(0)}  (1:${ratio})`,
    bbX + bbW - 12,
    dotY + 4,
  );

  // hint underneath if punishment is off and we've seen collapse
  ctx.textAlign = "center";
  ctx.fillStyle = "#666";
  ctx.font = "10px monospace";
  ctx.fillText(
    "click anywhere to toggle · drag Y for punishment cost (top=cheap, bottom=expensive)",
    W / 2,
    bbY - 4,
  );
  ctx.textAlign = "left";
}

Comments (0)

Log in to comment.