Prisoner's Dilemma

Prisoner's Dilemma simulator where different strategies vie for survival in different world setups.

---
format: typebulb/v1
name: "Prisoner's Dilemma"
---

**code.tsx**

```tsx
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";

const CPU_THROTTLE = 0.2;
const COOP = 1, DEFECT = 0;
type Payoffs = [[number, number], [number, number]];
type StrategyFn = (myHist: number[], oppHist: number[], payoffs: Payoffs) => number;
type Strategy = { name: string; desc: string; color: string; fn: StrategyFn };

const countCoop = (hist: number[]) => hist.filter(x => x).length;
const payoff = (a: number, b: number, p: Payoffs) => p[a][b];
const scoreDiff = (my: number[], opp: number[], p: Payoffs) =>
  my.reduce((d, v, i) => d + payoff(v, opp[i], p) - payoff(opp[i], v, p), 0);
const getPayoffs = (T: number): Payoffs => [[1, T], [0, 3]];

const STRATEGIES: Strategy[] = [
  { name: "Always Cooperate", desc: "Always cooperates, no matter what the opponent does.", color: "#34d399",
    fn: () => COOP },
  { name: "Always Defect", desc: "Always defects and never rewards cooperation.", color: "#ef4444",
    fn: () => DEFECT },
  { name: "Tit for Tat", desc: "Starts by cooperating, then simply copies the opponent's last move.", color: "#4ade80",
    fn: (_, o) => o.at(-1) ?? COOP },
  { name: "Pavlov", desc: "Repeats its last move if the last outcome was good, otherwise switches; a win-stay, lose-shift learner.", color: "#a78bfa",
    fn: (m, o, p) => !m.length ? COOP : payoff(m.at(-1)!, o.at(-1)!, p) >= 3 ? m.at(-1)! : 1 - m.at(-1)! },
  { name: "Grim Trigger", desc: "Cooperates until the opponent defects once, then defects forever in retaliation.", color: "#f87171",
    fn: (_, o) => o.includes(DEFECT) ? DEFECT : COOP },
  { name: "Random", desc: "Acts like a coin flip each round, cooperating or defecting at random.", color: "#fb923c",
    fn: () => Math.random() < 0.5 ? COOP : DEFECT },
  { name: "Tit for Two Tats", desc: "Usually cooperates, but defects only after two opponent defections in a row.", color: "#a3e635",
    fn: (_, o) => o.at(-1) === DEFECT && o.at(-2) === DEFECT ? DEFECT : COOP },
  { name: "Generous TFT", desc: "Copies the opponent but sometimes forgives a defection and cooperates anyway.", color: "#6ee7b7",
    fn: (_, o) => !o.length || o.at(-1) === COOP || Math.random() < 0.1 ? COOP : DEFECT },
  { name: "Joss", desc: "Like Tit for Tat but occasionally throws in an unprovoked defection to test or exploit.", color: "#f97316",
    fn: (_, o) => { const t = o.at(-1) ?? COOP; return t === COOP && Math.random() < 0.1 ? DEFECT : t; } },
  { name: "Forgiving Grim", desc: "Punishes defection for a few rounds, then forgives and returns to cooperation if things stay calm.", color: "#fb7185",
    fn: (_, o) => { const l = o.lastIndexOf(DEFECT); return l >= 0 && o.length - 1 - l < 5 ? DEFECT : COOP; } },
  { name: "Majority", desc: "Looks at the whole history and cooperates if the opponent has cooperated more than half the time.", color: "#60a5fa",
    fn: (_, o) => !o.length || countCoop(o) > o.length / 2 ? COOP : DEFECT },
  { name: "Gradual", desc: "Escalates punishment step by step when betrayed, then backs down and reconciles after enough payback.", color: "#14b8a6",
    fn: (m, o) => !o.length ? COOP : m.at(-1) === DEFECT && m.at(-2) === DEFECT ? COOP : o.at(-1) === DEFECT ? DEFECT : countCoop(m) > countCoop(o) ? DEFECT : COOP },
  { name: "Prober", desc: "Starts with three defections to test for naive cooperators, then either keeps exploiting or switches to mirroring.", color: "#fbbf24",
    fn: (m, o) => m.length < 3 ? DEFECT : o.slice(0, 3).every(x => x === COOP) ? DEFECT : o.at(-1)! },
  { name: "Adaptive", desc: "Tracks the score and switches to defection if it falls far behind, otherwise it mostly stays cooperative.", color: "#8b5cf6",
    fn: (m, o, p) => !o.length ? COOP : scoreDiff(m, o, p) < -10 ? DEFECT : o.at(-1) === DEFECT ? DEFECT : COOP },
  { name: "Equalizer", desc: "Monitors score differences and adjusts between punishing and cooperating to keep the gap under control.", color: "#06b6d4",
    fn: (m, o, p) => { if (!o.length) return COOP; const d = scoreDiff(m, o, p); return d < 0 ? DEFECT : d > 3 ? COOP : o.at(-1) ?? COOP; } },
  { name: "Extortionist", desc: "Changes how likely it is to cooperate based on the last outcome, nudging the opponent into worse deals over time.", color: "#e11d48",
    fn: (m, o) => { if (!m.length) return Math.random() < 0.5 ? COOP : DEFECT; const pr = m.at(-1) ? (o.at(-1) ? 0.8 : 0.1) : (o.at(-1) ? 0.6 : 0.2); return Math.random() < pr ? COOP : DEFECT; } },
];

const NUM_STRAT = STRATEGIES.length;
const STRAT_NAMES = STRATEGIES.map(s => s.name);
const BY_NAME = Object.fromEntries(STRATEGIES.map(s => [s.name, s]));

type PresetParams = { popSize: number; rounds: number; mutate: number; noise: number; temptation?: number };
type WorldPreset = { name: string; desc: string; params: PresetParams };

const PRESETS: WorldPreset[] = [
  { name: "Fool Me Twice", desc: "Long noisy games where turning the other cheek is the wise move.",
    params: { popSize: 1, rounds: 2, mutate: 0, noise: 2, temptation: 0 } },
  { name: "Grudge Match", desc: "Long, moderately noisy, high-temptation games where Gradual's punish-then-forgive cycle dominates the population.",
    params: { popSize: 0, rounds: 2, mutate: 0, noise: 1, temptation: 2 } },
  { name: "Pavlov's Doghouse", desc: "Medium-length games with moderate noise and low temptation where Pavlov (win–stay, lose–shift) eventually dominates.",
    params: { popSize: 0, rounds: 1, mutate: 0, noise: 1, temptation: 0 } },
  { name: "The Bigger Man", desc: "Medium games in a clean, moderately tempting world where Generous TFT eventually wins by forgiving occasional defections.",
    params: { popSize: 1, rounds: 1, mutate: 0, noise: 0, temptation: 1 } },
  { name: "Justice Field", desc: "Medium-length, moderately noisy, high-temptation games where Equalizer enforces tight, extortionate payoff control.",
    params: { popSize: 0, rounds: 1, mutate: 0, noise: 1, temptation: 2 } },
  { name: "Grim Reality", desc: "Very short, very clean, low-temptation games where harsh one-strike punishment (Grim Trigger) is the most successful rule.",
    params: { popSize: 0, rounds: 0, mutate: 0, noise: 0, temptation: 0 } },
  { name: "Forgiving Dictatorship", desc: "Large, noisy medium-length games with moderate mutation where Forgiving Grim rules: harsh but willing to eventually reset.",
    params: { popSize: 2, rounds: 1, mutate: 1, noise: 2, temptation: 1 } },
  { name: "Tyranny of the Majority", desc: "Short, noisy, high-mutation games where crowd-following and grim-style punishers, including Majority, do unusually well.",
    params: { popSize: 1, rounds: 0, mutate: 2, noise: 1, temptation: 0 } },
  { name: "The Purge", desc: "Short, extremely noisy, high-temptation games in a large population where Prober thrives by testing and ruthlessly exploiting.",
    params: { popSize: 2, rounds: 0, mutate: 0, noise: 2, temptation: 2 } },
  { name: "Hobbesian Hellscape", desc: "Short, very noisy, moderately tempting games where cooperation collapses and Always Defect reliably takes over.",
    params: { popSize: 2, rounds: 0, mutate: 1, noise: 2, temptation: 1 } },
  { name: "Mafia State", desc: "Short, clean, high-temptation world where extortion pays.",
    params: { popSize: 0, rounds: 0, mutate: 0, noise: 0, temptation: 2 } },
  { name: "Paranoid Planet", desc: "Medium-length, clean, high-temptation world where Joss (TFT plus random defection) is the dominant paranoid cooperator.",
    params: { popSize: 1, rounds: 1, mutate: 0, noise: 0, temptation: 2 } },
  { name: "Naive Utopia", desc: "Very long, very noisy, high-mutation world where Tit for Two Tats dominates but Always Cooperate survives as a stable minority niche.",
    params: { popSize: 1, rounds: 2, mutate: 2, noise: 2, temptation: 0 } },
];

const PARAMS = {
  popSize: { steps: [10, 20, 30], default: 1, label: "Pop", title: "Population size" },
  rounds: { steps: [3, 10, 30], default: 1, label: "Rounds", title: "Rounds per match" },
  noise: { steps: [0.01, 0.05, 0.2], default: 0, label: "Noise", title: "Move flip chance" },
  mutate: { steps: [0.01, 0.05, 0.2], default: 0, label: "Mutate", title: "Mutation rate" },
  temptation: { steps: [4, 5, 7], default: 1, label: "Temptation", title: "Payoff for exploiting cooperator" },
} as const;

type ParamKey = keyof typeof PARAMS;
type ParamValues = Record<ParamKey, number>;
type HistEntry = { gen: number; avgCoop: number; dist: Record<string, number> };
type SimState = { gen: number; pop: Uint8Array; fit: Float32Array; coop: Float32Array };
type UIState = { gen: number; avgCoop: number; dist: Record<string, number>; history: HistEntry[]; totals: Record<string, number> };

const getP = (k: ParamKey, i: number) => PARAMS[k].steps[i];
const fmtP = (k: ParamKey, i: number) => k === "mutate" || k === "noise" ? `${Math.round(getP(k, i) * 100)}%` : k === "temptation" ? `T=${getP(k, i)}` : String(getP(k, i));
const rand = (n: number) => (Math.random() * n) | 0;
const sum = (a: number[] | Record<string, number>) => Object.values(a).reduce((x, y) => x + y, 0);
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const initRec = (v = 0) => Object.fromEntries(STRAT_NAMES.map(n => [n, v]));
const defaultParams = Object.fromEntries(Object.entries(PARAMS).map(([k, v]) => [k, v.default])) as ParamValues;
const initUI = (): UIState => ({ gen: 0, avgCoop: 0, dist: initRec(), history: [], totals: initRec() });

const runMatch = (i1: number, i2: number, rounds: number, noise: number, payoffs: Payoffs) => {
  const [s1, s2] = [STRATEGIES[i1].fn, STRATEGIES[i2].fn];
  const [h1, h2]: number[][] = [[], []];
  let sc1 = 0, sc2 = 0, co1 = 0, co2 = 0;
  for (let r = 0; r < rounds; r++) {
    let m1 = s1(h1, h2, payoffs), m2 = s2(h2, h1, payoffs);
    if (Math.random() < noise) m1 = 1 - m1;
    if (Math.random() < noise) m2 = 1 - m2;
    sc1 += payoff(m1, m2, payoffs); sc2 += payoff(m2, m1, payoffs);
    co1 += m1; co2 += m2;
    h1.push(m1); h2.push(m2);
  }
  return { sc1, sc2, co1, co2 };
};

const evaluate = (pop: Uint8Array, rounds: number, noise: number, payoffs: Payoffs) => {
  const n = pop.length, scores = new Float32Array(n), coops = new Float32Array(n), matches = new Uint16Array(n);
  for (let i = 0; i < n; i++) for (let j = i; j < n; j++) {
    const { sc1, sc2, co1, co2 } = runMatch(pop[i], pop[j], rounds, noise, payoffs);
    scores[i] += sc1; scores[j] += sc2; coops[i] += co1; coops[j] += co2; matches[i]++; matches[j]++;
  }
  return { fit: scores.map((s, i) => s / matches[i]), coop: coops.map((c, i) => (c / (matches[i] * rounds)) * 100) };
};

const evolve = (pop: Uint8Array, fit: Float32Array, size: number, mutRate: number) => {
  const next = new Uint8Array(size);
  for (let i = 0; i < size; i++) {
    let best = rand(pop.length);
    for (let t = 0; t < 2; t++) { const c = rand(pop.length); if (fit[c] > fit[best]) best = c; }
    next[i] = Math.random() < mutRate ? rand(NUM_STRAT) : pop[best];
  }
  return next;
};

const initSim = (size: number, rounds: number, noise: number, payoffs: Payoffs): SimState => {
  const pop = new Uint8Array(size).map(() => rand(NUM_STRAT));
  return { gen: 0, pop, ...evaluate(pop, rounds, noise, payoffs) };
};

const stepSim = (s: SimState, size: number, rounds: number, mut: number, noise: number, payoffs: Payoffs): SimState => {
  const pop = evolve(s.pop, s.fit, size, mut);
  return { gen: s.gen + 1, pop, ...evaluate(pop, rounds, noise, payoffs) };
};

const getDist = (pop: Uint8Array) => {
  const c = Array(NUM_STRAT).fill(0);
  pop.forEach(i => c[i]++);
  return Object.fromEntries(STRAT_NAMES.map((n, i) => [n, c[i]]));
};

const calcAvgCoop = (r: Float32Array) => r.reduce((a, b) => a + b, 0) / r.length;

function App() {
  const [params, setParams] = useState<ParamValues>(defaultParams);
  const [ui, setUI] = useState<UIState>(initUI);
  const [running, setRunning] = useState(true);
  const [preset, setPreset] = useState(0);
  const simRef = useRef<SimState | null>(null);
  const paramsRef = useRef(params); paramsRef.current = params;
  const runningRef = useRef(running); runningRef.current = running;

  const reset = (p: ParamValues) => {
    setRunning(false);
    simRef.current = initSim(getP("popSize", p.popSize), getP("rounds", p.rounds), getP("noise", p.noise), getPayoffs(getP("temptation", p.temptation)));
    setUI(initUI());
    setTimeout(() => setRunning(true), 50);
  };

  useEffect(() => {
    const presetParams = { ...defaultParams, ...PRESETS[0].params };
    setParams(presetParams);
    const p = presetParams;
    simRef.current = initSim(getP("popSize", p.popSize), getP("rounds", p.rounds), getP("noise", p.noise), getPayoffs(getP("temptation", p.temptation)));
    setUI(u => ({ ...u, dist: getDist(simRef.current!.pop), avgCoop: calcAvgCoop(simRef.current!.coop) }));
  }, []);

  useEffect(() => {
    if (!running) return;
    let frameId: number, idleUntil = 0;
    const tick = (time: number) => {
      if (!runningRef.current || !simRef.current || time < idleUntil) { frameId = requestAnimationFrame(tick); return; }
      const t0 = performance.now(), p = paramsRef.current;
      const [popSize, rounds, mut, noise] = (["popSize", "rounds", "mutate", "noise"] as ParamKey[]).map(k => getP(k, p[k]));
      const payoffs = getPayoffs(getP("temptation", p.temptation));
      const steps = popSize <= 18 ? 3 : popSize <= 24 ? 2 : 1;
      for (let i = 0; i < steps; i++) simRef.current = stepSim(simRef.current!, popSize, rounds, mut, noise, payoffs);
      const dist = getDist(simRef.current.pop), { gen, coop } = simRef.current, coopRate = calcAvgCoop(coop);
      setUI(u => ({
        gen, avgCoop: coopRate, dist,
        history: [...u.history.slice(-99), { gen, avgCoop: coopRate, dist }],
        totals: Object.fromEntries(STRAT_NAMES.map(n => [n, u.totals[n] + dist[n] * steps]))
      }));
      idleUntil = time + (performance.now() - t0) * (1 - CPU_THROTTLE) / CPU_THROTTLE;
      frameId = requestAnimationFrame(tick);
    };
    frameId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frameId);
  }, [running]);

  const applyPreset = (i: number) => {
    setPreset(i);
    const p = i >= 0 ? { ...defaultParams, ...PRESETS[i].params } : params;
    setParams(p);
    reset(p);
  };

  const T = getP("temptation", params.temptation);

  return (
    <div className="container">
      <h1><span className="emoji">😇😈</span><span className="gradient-text">Prisoner's Dilemma</span></h1>
      <div className="preset-row">
        <div className="world-selector">
          <label className="preset-label">World:</label>
          <select className="preset-select" value={preset} onChange={e => applyPreset(+e.target.value)}>
            <option value={-1}>Custom</option>
            {PRESETS.map((p, i) => <option key={p.name} value={i}>{p.name}</option>)}
          </select>
          <span className="preset-desc">{preset >= 0 ? PRESETS[preset].desc : "Manually configured parameters"}</span>
        </div>
        <div className="inline-payoff">
          <div className="inline-payoff-header">
            <span className="inline-payoff-title">Payoffs</span>
            <span className="inline-payoff-temptation">T = {T}</span>
          </div>
          <table className="payoff-table payoff-table--compact">
            <thead><tr><th></th><th>C</th><th>D</th></tr></thead>
            <tbody>
              <tr><td><strong>C</strong></td><td className="good">3, 3</td><td className="bad">0, {T}</td></tr>
              <tr><td><strong>D</strong></td><td className="tempt">{T}, 0</td><td className="neutral">1, 1</td></tr>
            </tbody>
          </table>
          <p className="inline-payoff-hint">C,C is mutual cooperation; D vs C gets T; D,D is the low, stable outcome.</p>
        </div>
      </div>
        <div className="params-row">
        {(["temptation", "rounds", "noise", "mutate", "popSize"] as ParamKey[]).map(k => (
          <div key={k} className="param-item">
            <label className="param-label" title={PARAMS[k].title}>{PARAMS[k].label}</label>
            <input type="range" min={0} max={2} value={params[k]} className="param-slider" title={PARAMS[k].title}
              onChange={e => { setPreset(-1); setParams(p => ({ ...p, [k]: +e.target.value })); }} />
            <span className="param-value">{fmtP(k, params[k])}</span>
          </div>
        ))}
      </div>
      <div className="controls">
        <div className="controls-left">
          <button onClick={() => setRunning(!running)} className={running ? "btn-stop" : "btn-start"}>{running ? "⏸ Pause" : "▶ Run"}</button>
          <button onClick={() => reset(params)}>🔄 Reset</button>
        </div>
        <div className="controls-right">
          <span className="info">Gen: <strong className="stat-number">{String(ui.gen).padStart(5, '\u2007')}</strong></span>
          <span className="info">Coop: <strong className="stat-number">{ui.avgCoop.toFixed(0).padStart(3, '\u2007')}%</strong></span>
        </div>
      </div>
      <section className="type-distribution">
        <h2>♞ Strategy Prevalence</h2>
        <Chart history={ui.history} totals={ui.totals} popSize={getP("popSize", params.popSize)} />
      </section>
    </div>
  );
}

const WINDOW = 5;

function Chart({ history, totals, popSize }: { history: HistEntry[]; totals: Record<string, number>; popSize: number }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(600);

  useEffect(() => {
    if (!containerRef.current) return;
    const obs = new ResizeObserver(([e]) => setWidth(Math.max(400, e.contentRect.width)));
    obs.observe(containerRef.current);
    return () => obs.disconnect();
  }, []);

  const [W, H] = [width, 250], P = { t: 20, r: Math.min(140, Math.max(100, W * 0.15)), b: 40, l: 50 };
  const [cW, cH] = [W - P.l - P.r, H - P.t - P.b];
  const total = sum(totals);
  const ranked = STRAT_NAMES.map(n => ({ name: n, pct: total ? (totals[n] / total) * 100 : 0 })).sort((a, b) => b.pct - a.pct);
  const smoothed = history.map((e, i) => ({
    ...e, dist: Object.fromEntries(STRAT_NAMES.map(n => [n, avg(history.slice(Math.max(0, i - WINDOW + 1), i + 1).map(h => h.dist[n] || 0))]))
  }));
  const recent = history.slice(-WINDOW);
  const recentDist = Object.fromEntries(STRAT_NAMES.map(n => [n, recent.length ? Math.round(avg(recent.map(h => h.dist[n] || 0))) : 0]));
  const [minG, maxG] = smoothed.length ? [smoothed[0].gen, smoothed.at(-1)!.gen] : [0, 1];
  const range = Math.max(maxG - minG, 1);
  const xS = (g: number) => P.l + ((g - minG) / range) * cW, yS = (c: number) => P.t + cH - (c / popSize) * cH;

  const paths = STRATEGIES.map(({ name, color }) => {
    const pts = smoothed.map(e => ({ x: xS(e.gen), y: yS(e.dist[name] || 0) }));
    return pts.length ? { name, path: pts.map((pt, i) => `${i ? "L" : "M"}${pt.x} ${pt.y}`).join(" "), color, last: pts.at(-1)! } : null;
  }).filter(Boolean) as { name: string; path: string; color: string; last: { x: number; y: number } }[];

  const yTicks = [0, .25, .5, .75, 1].map(f => Math.round(f * popSize));
  const xTicks = [...new Set(Array.from({ length: Math.min(5, range) + 1 }, (_, i) => Math.round(minG + (i / Math.min(5, range)) * range)))];

  return (
    <div className="line-chart-container" ref={containerRef}>
      <svg viewBox={`0 0 ${W} ${H}`} className="line-chart-svg">
        {!smoothed.length ? (
          <text x={W / 2} y={H / 2} textAnchor="middle" fill="var(--text-secondary)" fontSize="14">Run simulation to see strategy evolution</text>
        ) : (<>
          {yTicks.map(t => <line key={t} x1={P.l} y1={yS(t)} x2={P.l + cW} y2={yS(t)} stroke="var(--border-color)" strokeDasharray="3,3" />)}
          <line x1={P.l} y1={P.t} x2={P.l} y2={P.t + cH} stroke="var(--text-secondary)" />
          {yTicks.map(t => <text key={t} x={P.l - 10} y={yS(t) + 4} textAnchor="end" fill="var(--text-secondary)" fontSize="11">{t}</text>)}
          <text x={15} y={P.t + cH / 2} textAnchor="middle" fill="var(--text-secondary)" fontSize="12" transform={`rotate(-90, 15, ${P.t + cH / 2})`}>Count</text>
          <line x1={P.l} y1={P.t + cH} x2={P.l + cW} y2={P.t + cH} stroke="var(--text-secondary)" />
          {xTicks.map(t => <text key={t} x={xS(t)} y={P.t + cH + 20} textAnchor="middle" fill="var(--text-secondary)" fontSize="11">{t}</text>)}
          <text x={P.l + cW / 2} y={H - 5} textAnchor="middle" fill="var(--text-secondary)" fontSize="12">Generation</text>
          {paths.map(p => (
            <g key={p.name}>
              <path d={p.path} fill="none" stroke={p.color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              <circle cx={p.last.x} cy={p.last.y} r="4" fill={p.color} />
            </g>
          ))}
          {paths.filter(p => recentDist[p.name] > 0).sort((a, b) => recentDist[b.name] - recentDist[a.name]).map((p, i) => (
            <g key={p.name} transform={`translate(${P.l + cW + 10}, ${P.t + i * 18})`}>
              <line x1="0" y1="0" x2="15" y2="0" stroke={p.color} strokeWidth="2" />
              <text x="20" y="4" fill="var(--text-primary)" fontSize="10">{p.name === "Tit for Tat" ? "TFT" : p.name} ({recentDist[p.name]})</text>
            </g>
          ))}
        </>)}
      </svg>
      <div className="lifetime-table-container">
        <h2 className="lifetime-table-title">🤼 Lifetime Dominance</h2>
        <table className="lifetime-table">
          <thead><tr><th className="col-color"></th><th className="col-strategy">Strategy</th><th className="col-description">Description</th><th className="col-dominance">Dominance</th></tr></thead>
          <tbody>
            {ranked.map(({ name, pct }) => (
              <tr key={name} className={pct === 0 ? "row-zero" : ""}>
                <td className="col-color"><span className="color-swatch" style={{ background: BY_NAME[name].color }} /></td>
                <td className="col-strategy">{name}</td>
                <td className="col-description">{BY_NAME[name].desc}</td>
                <td className="col-dominance">
                  <div className="dominance-bar-container">
                    <div className="dominance-bar-track">
                      <div className="dominance-bar" style={{ width: `${pct}%`, background: BY_NAME[name].color }} />
    </div>
                    <span className="dominance-value">{pct.toFixed(1)}%</span>
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

createRoot(document.getElementById("root")!).render(<App />);
```
**styles.css**

```css
:root {
  --bg-gradient-start: #1f1f1f;
  --bg-gradient-end: #121212;
  --text-primary: #e4e4e7;
  --text-secondary: #9ca3af;
  --accent-gold: #fbbf24;
  --accent-green: #4ade80;
  --accent-blue: #60a5fa;
  --accent-purple: #c084fc;
  --accent-red: #f87171;
  --surface-overlay: rgba(255, 255, 255, 0.05);
  --surface-overlay-dark: rgba(0, 0, 0, 0.3);
  --border-color: rgba(255, 255, 255, 0.1);
}

html[data-theme="light"] {
  --bg-gradient-start: #f0f4f8;
  --bg-gradient-end: #e2e8f0;
  --text-primary: #1a202c;
  --text-secondary: #4a5568;
  --accent-gold: #d97706;
  --accent-green: #059669;
  --accent-blue: #2563eb;
  --accent-purple: #7c3aed;
  --accent-red: #b91c1c;
  --surface-overlay: rgba(255, 255, 255, 0.8);
  --surface-overlay-dark: rgba(0, 0, 0, 0.05);
  --border-color: rgba(0, 0, 0, 0.1);
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
  color: var(--text-primary);
  min-height: 100vh;
  padding: 20px;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
}

h1 {
  text-align: center;
  font-size: 2.2rem;
  margin-bottom: 20px;
}

h1 .gradient-text {
  background: linear-gradient(90deg, var(--accent-gold), var(--accent-purple));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

h1 .emoji {
  margin-right: 0.3em;
}

h2 {
  font-size: 1.3rem;
  margin-bottom: 15px;
  color: var(--accent-gold);
  text-align: center;
}

/* Shared text styles */
.preset-label,
.param-label,
.lifetime-table th {
  font-size: 0.75rem;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 600;
}

.param-label {
  text-align: center;
}

.lifetime-table th {
  padding: 6px 10px;
  text-align: left;
  border-bottom: 2px solid var(--border-color);
}

/* Shared row containers */
.preset-row,
.params-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 20px;
  background: var(--surface-overlay);
  padding: 10px 16px;
  border-radius: 10px;
  margin-bottom: 10px;
}

.params-row {
  align-items: flex-start;
  justify-content: center;
}

.preset-row {
  gap: 14px;
  align-items: stretch;
}

/* Shared column layouts */
.world-selector {
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1 1 220px;
  min-width: 0;
  max-width: 50%;
}

/* Select and input controls */
.preset-select {
  padding: 6px 10px;
  border-radius: 6px;
  border: 1px solid var(--border-color);
  font-size: 0.85rem;
  font-weight: 500;
  cursor: pointer;
  width: fit-content;
  min-width: 140px;
  max-width: 100%;
}

.preset-select:focus {
  outline: 2px solid var(--accent-blue);
  outline-offset: 1px;
}

.preset-desc {
  font-size: 1rem;
  color: var(--text-secondary);
  font-style: italic;
}

.param-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  min-width: 60px;
}

/* Sliders */
.param-slider {
  width: 60px;
  cursor: pointer;
}

/* Shared numeric display styles */
.param-value,
.dominance-value {
  font-weight: 600;
  color: var(--text-primary);
  text-align: right;
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
}

.param-value {
  font-size: 0.75rem;
  color: var(--accent-gold);
  min-width: 28px;
}

.dominance-value {
  font-size: 0.85rem;
  min-width: 50px;
}

.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  background: var(--surface-overlay);
  padding: 15px 20px;
  border-radius: 12px;
  margin-bottom: 20px;
}

.controls-left {
  display: flex;
  gap: 12px;
  align-items: center;
}

.controls-right,
.inline-payoff-header,
.dominance-bar-container {
  display: flex;
  align-items: center;
}

.controls-right {
  gap: 20px;
}

.controls button {
  padding: 10px 18px;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
}

.controls button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-start {
  background: linear-gradient(135deg, #4ade80, #22c55e);
  color: #fff;
}

.btn-stop {
  background: linear-gradient(135deg, #f87171, #ef4444);
  color: #fff;
}

.controls button:not(.btn-start):not(.btn-stop) {
  background: linear-gradient(135deg, #60a5fa, #3b82f6);
  color: #fff;
}

.controls button:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}

.info {
  color: var(--text-secondary);
  font-size: 0.9rem;
}

.info strong {
  color: var(--accent-gold);
  font-size: 1.1rem;
}

.stat-number {
  font-family: "SF Mono", "Monaco", "Consolas", monospace;
  font-variant-numeric: tabular-nums;
  display: inline-block;
  text-align: right;
}

/* Shared section styles */
section {
  background: rgba(255, 255, 255, 0.03);
  border-radius: 16px;
  padding: 20px;
  margin-bottom: 20px;
}

/* Payoff table shared styles */
.payoff-section,
.inline-payoff {
  text-align: center;
}

.payoff-table th,
.payoff-table td {
  padding: 12px 20px;
  border: 1px solid var(--border-color);
}

.payoff-table th {
  background: var(--surface-overlay-dark);
}

.payoff-table--compact th,
.payoff-table--compact td {
  padding: 2px 6px;
  white-space: nowrap;
  text-align: center;
}

.type-distribution {
  padding: 12px 16px;
}

.payoff-table {
  margin: 0 auto 15px;
  border-collapse: collapse;
}

.payoff-table .good {
  background: rgba(74, 222, 128, 0.2);
  color: var(--accent-green);
}

.payoff-table .bad {
  background: rgba(248, 113, 113, 0.2);
  color: var(--accent-red);
}

.payoff-table .tempt {
  background: rgba(251, 191, 36, 0.2);
  color: var(--accent-gold);
}

.payoff-table .neutral {
  background: rgba(156, 163, 175, 0.2);
  color: var(--text-secondary);
}

.hint {
  font-size: 0.85rem;
  color: var(--text-secondary);
}

/* Compact payoff matrix embedded next to world selector */
.inline-payoff {
  flex: 0 1 150px;
  min-width: 110px;
  background: var(--surface-overlay-dark);
  border-radius: 8px;
  padding: 6px 8px;
  align-self: flex-start; /* let the card hug its content vertically */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}

.inline-payoff-header {
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 2px;
  width: 100%;
}

.inline-payoff-title,
.inline-payoff-temptation {
  font-weight: 700;
}

.inline-payoff-title {
  font-size: 0.95rem;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-secondary);
}

.inline-payoff-temptation {
  font-size: 0.95rem;
  color: var(--accent-gold);
}

.payoff-table--compact {
  font-size: 0.9rem;
  margin: 0;
  width: fit-content;   /* shrink-wrap to content */
  table-layout: auto;
}

.inline-payoff-hint {
  font-size: 0.85rem;
  color: var(--text-secondary);
  margin-top: 2px;
}

@media (max-width: 768px) {
  h1 {
    font-size: 1.6rem;
  }

  .preset-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: start;
    gap: 10px;
  }

  .world-selector {
    grid-column: 1 / 2;
    max-width: 100%;
  }

  .inline-payoff {
    grid-column: 2 / 3;
  }

  .preset-select {
    width: 100%;
  }

  .params-row {
    gap: 10px 16px;
    padding: 8px 12px;
  }

  .param-item {
    min-width: 50px;
  }

  .param-slider {
    width: 50px;
  }

  .controls {
    flex-direction: column;
    gap: 12px;
  }

  .controls-left,
  .controls-right {
    justify-content: center;
  }
}

.line-chart-container {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
}

.line-chart-svg {
  width: 100%;
  height: auto;
  min-height: 250px;
  aspect-ratio: auto;
  border-radius: 12px;
  background: var(--surface-overlay-dark);
}

.lifetime-table-container {
  margin-top: 12px;
  overflow: hidden;
}

.lifetime-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.85rem;
}

.lifetime-table td {
  padding: 8px 10px;
  border-bottom: 1px solid var(--border-color);
  vertical-align: middle;
}

.lifetime-table tr:last-child td {
  border-bottom: none;
}

.lifetime-table tr:hover:not(.row-zero) {
  background: var(--surface-overlay);
}

.lifetime-table .row-zero {
  opacity: 0.4;
}

.lifetime-table .col-color {
  width: 30px;
  text-align: center;
}

.lifetime-table .col-strategy {
  width: 160px;
  font-weight: 500;
  color: var(--text-primary);
  white-space: nowrap;
}

.lifetime-table .col-description {
  color: var(--text-secondary);
  font-size: 0.8rem;
}

.lifetime-table .col-dominance {
  width: 180px;
  min-width: 180px;
}

.color-swatch {
  display: inline-block;
  width: 14px;
  height: 14px;
  border-radius: 4px;
}

.dominance-bar-container {
  gap: 8px;
  width: 100%;
}

.dominance-bar-track {
  flex: 1;
  min-width: 60px;
  height: 8px;
  background: var(--surface-overlay-dark);
  border-radius: 4px;
  overflow: hidden;
  position: relative;
}

.dominance-bar {
  height: 100%;
  border-radius: 4px;
  min-width: 1px;
  position: absolute;
  left: 0;
  top: 0;
}

@media (max-width: 768px) {
  .lifetime-table .col-description {
    display: none;
  }

  .lifetime-table .col-strategy {
    width: auto;
  }

  .lifetime-table .col-dominance {
    width: 140px;
    min-width: 140px;
  }

  .dominance-bar-track {
    min-width: 40px;
  }

  .lifetime-table th {
    padding: 5px 8px;
    font-size: 0.7rem;
  }

  .lifetime-table td {
    padding: 6px 8px;
    font-size: 0.8rem;
  }

  .dominance-bar-track {
    display: none;
  }
}

@media (max-width: 480px) {
  .world-selector {
    max-width: 100%;
  }

  .preset-select {
    min-width: 80px;
    max-width: 140px;
  }
}
```
**index.html**

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Prisoner's Dilemma Tournament</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "description": "Prisoner's Dilemma simulator where different strategies vie for survival in different world setups."
}
```

Markdown source · More bulbs by samples · Typebulb home