12
Ultimatum Game Tournament
drag Y for rejection threshold · click to skip
idle
424 lines · vanilla
view source
// Ultimatum Game round-robin tournament.
// Proposer offers a split of $100 to Responder. Responder accepts (both get
// portion) or rejects (both get $0). 6 strategies play 200 rounds per pair.
// Left panel: live-sorted cumulative earnings bar chart.
// Right panel: current matchup with offer + responder decision animated.
// Interaction: mouseY scrubs population rejection-threshold mean.
// click skips to next matchup.
const ROUNDS = 200;
const POT = 100;
// Strategy definitions.
// Each has:
// propose(state, ctx) -> integer offer 0..100 to responder
// accept(offer, state, ctx) -> boolean
// afterRound(myWasProposer, myOffer, oppOffer, accepted, state)
// ctx exposes thresholdMean (population pressure) so strategies can read it.
const STRATS = [
{
name: 'fair',
color: '#7fe3a2',
blurb: 'offers 50, demands >=30',
init: () => ({}),
propose: () => 50,
accept: (o) => o >= 30,
afterRound: () => {},
},
{
name: 'greedy',
color: '#ff6b6b',
blurb: 'offers 10, takes any >0',
init: () => ({}),
propose: () => 10,
accept: (o) => o > 0,
afterRound: () => {},
},
{
name: 'tit-for-mean',
color: '#7ad7ff',
blurb: 'adapts to mean received',
init: () => ({ recv: [], mean: 30 }),
propose: (s) => Math.max(1, Math.min(50, Math.round(s.mean))),
accept: (o, s) => o >= Math.max(1, s.mean * 0.6),
afterRound: (wasProp, myOffer, oppOffer, acc, s) => {
if (!wasProp) {
s.recv.push(oppOffer);
// EMA toward incoming offers
s.mean = s.mean * 0.85 + oppOffer * 0.15;
}
},
},
{
name: 'random',
color: '#ffd66b',
blurb: 'offer 0-50, random threshold',
init: () => ({ thr: Math.floor(Math.random() * 35) }),
propose: () => Math.floor(Math.random() * 51),
accept: (o, s) => o >= s.thr,
afterRound: (wasProp, myO, oO, acc, s) => {
// reroll threshold each round for noisiness
s.thr = Math.floor(Math.random() * 35);
},
},
{
name: 'inequity-averse',
color: '#c779ff',
blurb: 'offer ~35, rejects below T',
init: (ctx) => {
// T drawn from a distribution around the population threshold mean.
const base = ctx && typeof ctx.thresholdMean === 'number' ? ctx.thresholdMean : 28;
// Normal-ish via central limit
const u = (Math.random() + Math.random() + Math.random()) / 3;
const T = Math.max(5, Math.min(45, base + (u - 0.5) * 30));
return { T };
},
propose: () => 35 + ((Math.random() * 5) | 0) - 2, // 33..37
accept: (o, s) => o >= s.T,
afterRound: () => {},
},
{
name: 'cooperator-grim',
color: '#ff9d4a',
blurb: 'fair until rejected, then greedy',
init: () => ({ betrayed: false }),
propose: (s) => (s.betrayed ? 10 : 50),
accept: (o, s) => (s.betrayed ? o > 0 : o >= 30),
afterRound: (wasProp, myOffer, oppOffer, accepted, s) => {
if (wasProp && !accepted) s.betrayed = true;
},
},
];
const N = STRATS.length;
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;
}
// ---- state ----
let W = 0, H = 0;
let schedule;
let matchIdx;
let round;
let stratStates; // [stateA, stateB]
let cumScore; // Float64Array length N
let phase; // 'play' | 'finished'
let finishedTimer;
let stepsPerFrame;
// Per-round display animation
let anim = {
proposerIdx: 0, // 0=A is proposer, 1=B is proposer (within match)
offer: 0,
accepted: false,
aPay: 0,
bPay: 0,
t: 0, // seconds since round started
duration: 0, // seconds this round is shown
};
// Interaction
let prevDown = false;
// Population threshold mean (scrubbed by mouseY). Initial value 28.
let thresholdMean = 28;
function freshMatch(i, j) {
const ctx = { thresholdMean };
stratStates = [STRATS[i].init(ctx), STRATS[j].init(ctx)];
round = 0;
}
function clampOffer(o) {
o = Math.round(o);
if (o < 0) o = 0;
if (o > POT) o = POT;
return o;
}
// Population-level rejection pressure: tilt each strategy's effective
// acceptance threshold toward thresholdMean. Strategies remain themselves
// but the population mood shifts what counts as acceptable.
function effectiveAccept(strat, offer, state) {
const intrinsic = strat.accept(offer, state);
// Population mood: a fraction of responders also veto offers below the
// population mean threshold. Weight grows as thresholdMean rises.
// pressure in [0,1] maps mouseY-driven mean to a rejection probability
// applied multiplicatively to offers below it.
if (offer < thresholdMean) {
// probability of being swayed: small near low mean, grows toward 1.
const w = Math.min(0.85, (thresholdMean - offer) / 50);
if (Math.random() < w) return false;
}
return intrinsic;
}
function stepMatch() {
const [i, j] = schedule[matchIdx];
const sA = STRATS[i], sB = STRATS[j];
// Alternate proposer each round
const aIsProposer = (round % 2) === 0;
const proposer = aIsProposer ? sA : sB;
const responder = aIsProposer ? sB : sA;
const propState = aIsProposer ? stratStates[0] : stratStates[1];
const respState = aIsProposer ? stratStates[1] : stratStates[0];
const ctx = { thresholdMean };
const rawOffer = proposer.propose(propState, ctx);
const offer = clampOffer(rawOffer);
const accepted = effectiveAccept(responder, offer, respState);
let aPay = 0, bPay = 0;
if (accepted) {
if (aIsProposer) { aPay = POT - offer; bPay = offer; }
else { aPay = offer; bPay = POT - offer; }
}
cumScore[i] += aPay;
cumScore[j] += bPay;
// Bookkeeping per strategy
sA.afterRound(aIsProposer, aIsProposer ? offer : 0, aIsProposer ? 0 : offer, accepted, stratStates[0]);
sB.afterRound(!aIsProposer, !aIsProposer ? offer : 0, !aIsProposer ? 0 : offer, accepted, stratStates[1]);
// Set up animation for this round
anim.proposerIdx = aIsProposer ? 0 : 1;
anim.offer = offer;
anim.accepted = accepted;
anim.aPay = aPay;
anim.bPay = bPay;
anim.t = 0;
// Round duration depends on stepsPerFrame: faster sim => shorter display.
// Aim for roughly 25-40 rounds/sec when scrubbing quickly.
anim.duration = Math.max(0.04, 0.12 / Math.max(1, stepsPerFrame));
round++;
}
function resetTournament() {
schedule = buildSchedule();
matchIdx = 0;
cumScore = new Float64Array(N);
freshMatch(schedule[0][0], schedule[0][1]);
phase = 'play';
finishedTimer = 0;
}
function skipToNextMatch() {
// Fast-forward any remaining rounds of the current match.
while (round < ROUNDS) stepMatch();
matchIdx++;
if (matchIdx >= schedule.length) {
phase = 'finished';
finishedTimer = 0;
return;
}
const [i, j] = schedule[matchIdx];
freshMatch(i, j);
}
function init({ ctx, width, height }) {
W = width; H = height;
// Target a ~15s playthrough across 15*200 = 3000 rounds => 200 rounds/sec.
// At 60fps that's ~3.3 rounds/frame. We'll start at 2 (slower so animation
// is visible) and the click control lets impatient users skip.
stepsPerFrame = 2;
resetTournament();
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, W, H);
}
// ---- drawing ----
function drawBg(ctx) {
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, W, H);
}
function drawHeader(ctx) {
ctx.fillStyle = '#e8ecf2';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('Ultimatum Tournament', 10, 8);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#9aa3b2';
ctx.textAlign = 'right';
if (phase === 'finished') {
ctx.fillText('Complete', W - 10, 10);
} else {
ctx.fillText(`Match ${matchIdx + 1}/${schedule.length} - Round ${Math.min(round, ROUNDS)}/${ROUNDS}`, W - 10, 10);
}
// Threshold bar (population rejection threshold). Across full width below
// the header, very thin.
const barY = 26;
const barX = 10, barW = W - 20, barH = 4;
ctx.fillStyle = '#1a1f29';
ctx.fillRect(barX, barY, barW, barH);
const tx = barX + (thresholdMean / 50) * barW;
ctx.fillStyle = '#22d3ee';
ctx.fillRect(barX, barY, Math.max(2, tx - barX), barH);
ctx.fillStyle = '#9aa3b2';
ctx.font = '10px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`pop threshold mean: ${thresholdMean.toFixed(0)}`, 10, barY + 7);
}
function drawLeftPanel(ctx) {
// Cumulative earnings bar chart, sorted live by score desc.
const panelX = 10;
const panelY = 50;
const panelW = Math.max(140, W * 0.5 - 14);
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 earnings ($)', panelX + 8, panelY + 6);
// Live sort by score desc.
const order = [];
for (let k = 0; k < N; k++) order.push(k);
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 - 8;
const rowH = (botY - topY) / N;
const nameW = Math.min(118, panelW * 0.38);
const barX0 = panelX + 6 + nameW;
const barXMax = panelX + panelW - 58;
const barSpan = Math.max(20, barXMax - barX0);
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 - 6, 18);
const s = cumScore[k];
const w = Math.max(0, (s / maxS) * barSpan);
// Rank
ctx.fillStyle = '#6b7280';
ctx.font = '10px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${r + 1}.`, panelX + 6, y);
ctx.fillStyle = STRATS[k].color;
ctx.font = '11px sans-serif';
ctx.fillText(STRATS[k].name, panelX + 20, y);
// Bar track
ctx.fillStyle = '#1a1f29';
ctx.fillRect(barX0, y - barH * 0.5, barSpan, barH);
// Bar
ctx.fillStyle = STRATS[k].color;
ctx.globalAlpha = 0.85;
ctx.fillRect(barX0, y - barH * 0.5, w, barH);
ctx.globalAlpha = 1;
// Score number
ctx.fillStyle = '#e8ecf2';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`$${Math.round(s)}`, barX0 + w + 4, y);
}
}
function drawRightPanel(ctx) {
// Current matchup with offer + response visualized.
const panelX = W * 0.5 + 4;
const panelY = 50;
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);
if (phase === 'finished') {
ctx.fillStyle = '#ffd66b';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Tournament complete', panelX + panelW * 0.5, panelY + panelH * 0.5);
ctx.fillStyle = '#9aa3b2';
ctx.font = '11px sans-serif';
ctx.fillText('resetting...', panelX + panelW * 0.5, panelY + panelH * 0.5 + 18);
return;
}
const [i, j] = schedule[matchIdx];
const a = STRATS[i], b = STRATS[j];
const proposerStratIdx = anim.proposerIdx === 0 ? i : j;
const responderStratIdx = anim.proposerIdx === 0 ? j : i;
const proposer = STRATS[proposerStratIdx];
const responder = STRATS[responderStratIdx];
// Title: matchup names with proposer / responder roles
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const cx = panelX + panelW * 0.5;
ctx.fillStyle = a.color;
ctx.fillText(a.name, panelX + panelW * 0.28, panelY + 6);
ctx.fillStyle = '#9aa3b2';
ctx.font = '11px sans-serif';
ctx.fillText('vs', cx, panelY + 7);
ctx.fillStyle = b.color;
ctx.font = 'bold 12px sans-serif';
ctx.fillText(b.name, panelX + panelW * 0.72, panelY + 6);
// Role tags (proposer / responder)
ctx.font = '10px sans-serif';
ctx.fillStyle = '#6b7280';
ctx.fillText(anim.proposerIdx === 0 ? 'proposer' : 'responder',
panelX + panelW * 0.28, panelY + 22);
ctx.fillText(anim.proposerIdx === 0 ? 'responder' : 'proposer',
panelX + panelW * 0.72, panelY + 22);
// Split bar visualizing the offer. Proposer's keep = POT - offer (left/blue).
// Responder's take = offer (right/orange).
const splitY = panelY + 50;
const splitH = 22;
const splitX0 = panelX + 14;
const splitW = panelW - 28;
ctx.fillStyle = '#1a1f29';
ctx.fillRect(splitX0, splitY, splitW, splitH);
// Animated reveal of the offer over the first half of round duration.
const tNorm = Math.min(1, anim.t / Math.max(0.001, anim.duration));
const revealOffer = anim.offer * Math.min(1, tNorm * 2);
const propKeepFrac = (POT - revealOffer) / POT;
const respGetFrac = revealOffer / POT;
// Proposer keep (left segment)
ctx.fillStyle = proposer.color;
ctx.globalAlpha = 0.75;
ctx.fillRect(splitX0, splitY, splitW * propKeepFrac, splitH);
// Responder get (right segment)
ctx.fillStyle = responder.color;
ctx.fillRect(splitX0 + splitW * propKeepFrac, splitY, splitW * respGetFrac, splitH);
ctx.globalAlpha = 1;
// Labels above the bar
ctx.font = '10px sans-serif';
ctx.fillStyle = '#cbd2dd';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(`keep $${Math.round(POT - revealOffer)}`, splitX0 + 2, splitY - 2);
ctx.textAlign = 'right';
ctx.fillText(`offer $${Math.round(revealOffer)}`, splitX0 + splitW - 2, splitY - 2);
// Marker for the population threshold mean on the split bar
const thrX = splitX0 + (thresholdMean / POT) * splitW;
ctx.strokeStyle = 'rgba(34,211,238,0.8)';
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(thrX, splitY - 4);
ctx.lineTo(thrX, splitY + splitH + 4);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(34,211,238,0.9)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('threshold', thrX, splitY + splitH + 5);
// Decision text (appears in second half of animation)
const decisionY = splitY + splitH + 28;
const decisionShown = tNorm > 0.45;
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
if (decisionShown) {
if (anim.accepted) {
ctx.fillStyle = '#3aa66a';
ctx.fillText('ACCEPT', cx, decisionY);
} else {
ctx.fillStyle = '#c84040';
ctx.fillText('REJECT', cx, decisionY);
}
ctx.font = '11px sans-serif';
ctx.fillStyle = '#9aa3b2';
if (anim.accepted) {
const propPay = POT - anim.offer;
const respPay = anim.offer;
ctx.fillText(`proposer +$${propPay} responder +$${respPay}`, cx, decisionY + 22);
} else {
ctx.fillText('both get $0', cx, decisionY + 22);
}
}
// Strategy blurbs below
const blurbY = decisionY + 44;
if (blurbY < panelY + panelH - 10) {
ctx.font = '10px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
ctx.fillStyle = proposer.color;
ctx.fillText(`${proposer.name} (P): `, panelX + 14, blurbY);
const pw = ctx.measureText(`${proposer.name} (P): `).width;
ctx.fillStyle = '#9aa3b2';
ctx.fillText(proposer.blurb, panelX + 14 + pw, blurbY);
ctx.fillStyle = responder.color;
ctx.fillText(`${responder.name} (R): `, panelX + 14, blurbY + 14);
const rw = ctx.measureText(`${responder.name} (R): `).width;
ctx.fillStyle = '#9aa3b2';
ctx.fillText(responder.blurb, panelX + 14 + rw, blurbY + 14);
}
// Hint at bottom
ctx.font = '10px sans-serif';
ctx.fillStyle = '#4b5563';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('drag Y: threshold - click: skip match',
panelX + panelW * 0.5, panelY + panelH - 4);
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
// --- input ---
// mouseY -> thresholdMean in 0..50 (top of canvas = 50, bottom = 0).
if (input && typeof input.mouseY === 'number') {
const my = Math.max(0, Math.min(H, input.mouseY));
const t = 1 - my / Math.max(1, H);
thresholdMean = Math.round(t * 50);
}
if (input && typeof input.consumeClicks === 'function') {
const clicks = input.consumeClicks();
if (clicks > 0 && phase === 'play') skipToNextMatch();
}
// --- simulate ---
if (phase === 'play') {
// Advance rounds. We pace by anim.t so the animation is visible.
anim.t += dt;
if (anim.t >= anim.duration) {
// Step one round per duration tick; if we're behind, allow catch-up.
const need = Math.min(8, Math.floor(anim.t / Math.max(0.001, anim.duration)));
for (let s = 0; s < need; s++) {
if (round >= ROUNDS) {
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.