← 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

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.

why switching wins

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:

P(stay wins)=13⟹P(switch wins)=1−13=23P(\text{stay wins}) = \tfrac{1}{3} \qquad\Longrightarrow\qquad P(\text{switch wins}) = 1 - \tfrac{1}{3} = \tfrac{2}{3}

The two must sum to 1, because after a goat is revealed exactly one of the two strategies wins every time — so pinning down either number fixes the other. 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 whole remaining 2-in-3 onto whichever door he left closed. Switching is betting that your first guess was wrong, which it usually was. That the host's knowledge is doing the work becomes obvious if you remove it: a host who opens a remaining door at random, and happens to reveal a goat, really does leave you at 50/50. Same visible outcome, different odds, because the process that produced it was different.

the controversy

The problem is named for the host of Let's Make a Deal, and it is genuinely notorious. Steve Selvin posed it in 1975, but it became famous in 1990 when Marilyn vos Savant answered it in her Parade column. She gave the correct answer and received something like 10,000 letters telling her she was wrong, roughly a thousand of them from people with PhDs, many on university mathematics department letterhead. Parade ran three more columns on it. It is a useful reminder that a confident, widely shared intuition can simply be incorrect.

why simulate

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 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. Watch the ratio settle as the count climbs; a dozen rounds proves nothing, but a few hundred lands close to 2/3 and stays there.

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;
        }
      `}</style>
    </div>
  );
}