6
Public Goods Game w/ Punishment
click to toggle punishment · drag Y for punishment cost
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.