12

Ultimatum Game Tournament

drag Y for rejection threshold · click to skip

A round-robin tournament of the classic Ultimatum Game: a Proposer offers a split of 100 to a Responder, who accepts (both get their share) or rejects (both get 0). Six strategies face off — `fair` (offers 50, demands ), `greedy` (offers 10, takes anything positive), `tit-for-mean` (tracks the EMA of incoming offers and matches it), `random` (uniform offer in with a re-rolled threshold each round), `inequity-averse` (offers around 35, rejects below a personal threshold drawn from a normal-ish distribution around the population mean), and `cooperator-grim` (fair until the first rejection against it, then permanently greedy). Each pair plays 200 rounds with proposer/responder alternating, and cumulative earnings update live on the sorted bar chart on the left. The right panel animates each round: the split bar reveals the offer with the proposer's keep on one side and the responder's take on the other, then the responder's decision flashes in green or red. Drag the cursor up and down to scrub the population-wide rejection-threshold mean — at low values, greedy splits sail through and exploiters dominate; pull it high and any sub-fair offer triggers indignant rejection, flipping the leaderboard toward the egalitarian strategies. Click anywhere to skip ahead to the next matchup. When all 15 pairings are done the tournament resets and runs again with fresh stochastic draws.

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.