← code

monty hall

The classic three-door probability puzzle — play it by hand, or simulate a few hundred rounds and watch switching pull ahead.

Pick a door — behind one is a car, behind the other two, goats.

stay
0/0 (0%)
switch
0/0 (0%)

Every round here uses a fresh random car door and a fresh random host tie-break, and every win/loss — whether from clicking through a round yourself or from the simulate buttons — feeds the same running totals below. Play a few rounds by hand, then simulate a few hundred to watch stay settle near 33% and switch settle near 67%.

The setup: three doors, one car, two goats. You pick a door, the host — who knows where the car is — opens a different door with a goat behind it, then asks if you want to switch to the last unopened door. Most people's gut says it's now 50/50 between the two remaining doors. It isn't: switching wins two out of three times, staying wins one out of three.

The trick is that your first pick locks in a 1-in-3 chance of being right before the host does anything, and nothing that happens afterward can change that. The host's reveal isn't random, either — he always avoids the car, so opening a door doesn't just remove a goat, it dumps the other 2-in-3 chance onto whichever door he left closed. Switching is betting that your first guess was wrong, which it usually was.

That explanation convinces some people and not others, which is exactly why the simulate buttons are here: the same instinct behind the busy beaver preset on the Turing machine page — when a claim about probability feels wrong, don't argue with it, run a few hundred trials and read off the answer. Every simulated round uses the same logic as clicking through one by hand, so the two feed the same running totals.

view source
import { useState } from 'react';

type Phase = 'pick' | 'decide' | 'done';
type Strategy = 'stay' | 'switch';

interface StratStats {
  games: number;
  wins: number;
}

function randomDoor(): number {
  return Math.floor(Math.random() * 3);
}

// The one non-picked, non-car door when there's a single candidate; a uniform
// coin flip between the two non-picked doors when the contestant already
// picked the car. Verified never to open the car or the picked door, and to
// split roughly 50/50 in the tie case, via a standalone brute-force check.
function hostOpens(carDoor: number, picked: number): number {
  const candidates = [0, 1, 2].filter((d) => d !== picked && d !== carDoor);
  if (candidates.length === 1) return candidates[0];
  return candidates[Math.floor(Math.random() * candidates.length)];
}

function remainingDoor(picked: number, opened: number): number {
  return [0, 1, 2].find((d) => d !== picked && d !== opened)!;
}

export default function MontyHall() {
  const [carDoor, setCarDoor] = useState(randomDoor);
  const [picked, setPicked] = useState<number | null>(null);
  const [opened, setOpened] = useState<number | null>(null);
  const [phase, setPhase] = useState<Phase>('pick');
  const [finalPick, setFinalPick] = useState<number | null>(null);
  const [lastStrategy, setLastStrategy] = useState<Strategy | null>(null);
  const [stats, setStats] = useState<Record<Strategy, StratStats>>({
    stay: { games: 0, wins: 0 },
    switch: { games: 0, wins: 0 },
  });

  const pickDoor = (i: number) => {
    if (phase !== 'pick') return;
    setPicked(i);
    setOpened(hostOpens(carDoor, i));
    setPhase('decide');
  };

  const decide = (strategy: Strategy) => {
    if (phase !== 'decide' || picked === null || opened === null) return;
    const final = strategy === 'stay' ? picked : remainingDoor(picked, opened);
    const win = final === carDoor;
    setFinalPick(final);
    setLastStrategy(strategy);
    setPhase('done');
    setStats((s) => ({
      ...s,
      [strategy]: {
        games: s[strategy].games + 1,
        wins: s[strategy].wins + (win ? 1 : 0),
      },
    }));
  };

  const playAgain = () => {
    setCarDoor(randomDoor());
    setPicked(null);
    setOpened(null);
    setPhase('pick');
    setFinalPick(null);
    setLastStrategy(null);
  };

  const simulate = (strategy: Strategy, n: number) => {
    let wins = 0;
    for (let i = 0; i < n; i++) {
      const car = randomDoor();
      const pick = randomDoor();
      const open = hostOpens(car, pick);
      const final = strategy === 'stay' ? pick : remainingDoor(pick, open);
      if (final === car) wins++;
    }
    setStats((s) => ({
      ...s,
      [strategy]: {
        games: s[strategy].games + n,
        wins: s[strategy].wins + wins,
      },
    }));
  };

  const resetStats = () =>
    setStats({ stay: { games: 0, wins: 0 }, switch: { games: 0, wins: 0 } });

  const doorContent = (i: number): string => {
    if (phase === 'done') return i === carDoor ? 'CAR' : 'GOAT';
    if (i === opened) return 'GOAT';
    return '?';
  };

  const doorClass = (i: number): string => {
    const classes = ['door'];
    if (i === picked) classes.push('picked');
    if (i === opened) classes.push('opened');
    if (phase === 'done' && i === finalPick)
      classes.push(i === carDoor ? 'win' : 'lose');
    return classes.join(' ');
  };

  const statusText = (): string => {
    if (phase === 'pick')
      return 'Pick a door — behind one is a car, behind the other two, goats.';
    if (phase === 'decide')
      return `Door ${opened! + 1} is a goat. Stay with door ${picked! + 1}, or switch?`;
    const won = finalPick === carDoor;
    const verb = lastStrategy === 'stay' ? 'stayed with' : 'switched to';
    return `Door ${opened! + 1} was a goat. You ${verb} door ${finalPick! + 1} — ${won ? "it's the car. You win." : "it's a goat. You lose."}`;
  };

  const pct = (s: StratStats) =>
    s.games ? Math.round((s.wins / s.games) * 100) : 0;

  return (
    <div className="monty-hall">
      <div className="doors">
        {[0, 1, 2].map((i) => (
          <button
            key={i}
            type="button"
            className={doorClass(i)}
            onClick={() => pickDoor(i)}
            disabled={phase !== 'pick'}
            aria-label={`door ${i + 1}`}
          >
            <span className="door-num">{i + 1}</span>
            <span className="door-content">{doorContent(i)}</span>
          </button>
        ))}
      </div>

      <p className="status">{statusText()}</p>

      <div className="controls">
        {phase === 'decide' && (
          <>
            <button type="button" onClick={() => decide('stay')}>
              stay
            </button>
            <button type="button" onClick={() => decide('switch')}>
              switch
            </button>
          </>
        )}
        {phase === 'done' && (
          <button type="button" onClick={playAgain}>
            play again
          </button>
        )}
      </div>

      <div className="stats">
        {(['stay', 'switch'] as Strategy[]).map((strat) => (
          <div className="stat-row" key={strat}>
            <span className="stat-label">{strat}</span>
            <div className="bar">
              <div
                className="bar-fill"
                style={{ width: `${pct(stats[strat])}%` }}
              />
            </div>
            <span className="stat-value">
              {stats[strat].wins}/{stats[strat].games} ({pct(stats[strat])}%)
            </span>
          </div>
        ))}

        <div className="sim-controls">
          <button type="button" onClick={() => simulate('stay', 100)}>
            simulate 100× stay
          </button>
          <button type="button" onClick={() => simulate('switch', 100)}>
            simulate 100× switch
          </button>
          <button type="button" onClick={resetStats}>
            reset stats
          </button>
        </div>
      </div>

      <p className="blurb">
        Every round here uses a fresh random car door and a fresh random host
        tie-break, and every win/loss — whether from clicking through a round
        yourself or from the simulate buttons — feeds the same running totals
        below. Play a few rounds by hand, then simulate a few hundred to watch
        stay settle near 33% and switch settle near 67%.
      </p>

      <style>{`
        .monty-hall {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1rem;
        }
        .monty-hall .doors {
          display: flex;
          gap: 0.75rem;
          justify-content: center;
          flex-wrap: wrap;
        }
        .monty-hall .door {
          font-family: inherit;
          display: flex;
          flex-direction: column;
          align-items: center;
          justify-content: center;
          gap: 0.5rem;
          width: 6.5rem;
          height: 6.5rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          cursor: pointer;
        }
        .monty-hall .door:not(:disabled):hover {
          color: #e6e6e6;
          border-color: #3a3f45;
        }
        .monty-hall .door:disabled {
          cursor: default;
        }
        .monty-hall .door-num {
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .monty-hall .door-content {
          font-size: 1.1rem;
          font-weight: bold;
        }
        .monty-hall .door.picked {
          border-color: var(--accent);
        }
        .monty-hall .door.opened {
          color: #5a5f66;
        }
        .monty-hall .door.win {
          border-color: var(--accent);
          color: var(--accent);
          background: rgba(124, 242, 156, 0.08);
        }
        .monty-hall .door.lose {
          border-color: #6a5a5a;
          color: #b08080;
        }
        .monty-hall .status {
          text-align: center;
          color: var(--fg);
          font-size: 0.85rem;
          margin: 1rem 0 0;
          min-height: 2.5em;
        }
        .monty-hall .controls {
          display: flex;
          justify-content: center;
          gap: 0.75rem;
          margin-top: 0.5rem;
          min-height: 2rem;
        }
        .monty-hall .controls button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.35rem 0.9rem;
          cursor: pointer;
        }
        .monty-hall .controls button:hover {
          color: #e6e6e6;
        }
        .monty-hall .stats {
          margin-top: 1.25rem;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
        }
        .monty-hall .stat-row {
          display: flex;
          align-items: center;
          gap: 0.75rem;
          font-size: 0.8rem;
          margin-bottom: 0.5rem;
        }
        .monty-hall .stat-label {
          width: 4rem;
          color: var(--muted);
          text-transform: uppercase;
          letter-spacing: 0.04em;
          font-size: 0.7rem;
        }
        .monty-hall .bar {
          flex: 1;
          height: 0.6rem;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
        }
        .monty-hall .bar-fill {
          height: 100%;
          background: var(--accent);
        }
        .monty-hall .stat-value {
          width: 8rem;
          text-align: right;
          color: var(--muted);
          font-variant-numeric: tabular-nums;
        }
        .monty-hall .sim-controls {
          display: flex;
          flex-wrap: wrap;
          gap: 0.5rem;
          margin-top: 0.75rem;
        }
        .monty-hall .sim-controls button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.3rem 0.7rem;
          cursor: pointer;
        }
        .monty-hall .sim-controls button:hover {
          color: #e6e6e6;
        }
        .monty-hall .blurb {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 1rem 0 0;
          max-width: 60ch;
        }
      `}</style>
    </div>
  );
}