thzinc

Spin the Wheel - Interview question of the week from rendezvous with cassidoo

Well, I spent quite a bit more time on this tonight than I’d originally anticipated. It’s not pretty, but it does work and has a little bit of viable game theory.

Interview question of the week

This week’s question: Implement a “spin the wheel” game where the player can bet on each spin of a wheel and either double their money, lose their money, or keep their money. You can choose how the user bets, and what data structures you might want to use for the player and their money!

My solution

Since there’s a lot of creative space left for me, I’m going to break the game down into a few key elements:

  1. A player has a balance of currency.
  2. A player can place a bet up to their balance; placing a bet deducts the amount from the balance
  3. A bet can have three possible outcomes:
    1. Win: the player’s balance is incremented 2*(bet)
    2. Draw: the player’s balance is incremented 1*(bet)
    3. Lose: the player’s balance is unchanged
  4. A player can request a loan from the banker
  5. If the loan balance is greater than zero
    1. A loan balance is increased by 2% for every bet
    2. A player can transfer an amount 0 < n < (loan balance) from balance to the loan balance
  6. Upon placing a bet, the wheel will spin for a short period of time and before choosing a randomly-selected outcome
  7. A player has a standing, which is (balance)-(loan balance)

<div id="root"></div>


body {
  font-family: sans-serif;
  padding: 0;
  margin: 0;
}

header {
  grid-area: header;
  text-align: center;
  font-family: cursive;
}

input[type="number"] {
  display: block;
  font-size: 1.5rem;
  width: 5rem;
}

.banker {
  grid-area: banker;
}

.balance {
  grid-area: balance;
}

.standing {
  grid-area: standing;
}

.balance figure,
.standing figure {
  font-size: 2.5rem;
  font-family: monospace;
}

.bet {
  grid-area: bet;
}

.wheel {
  grid-area: wheel;
  text-align: center;
}

.wheel .icon {
  font-size: 10rem;
  text-align: center;
}

.wheel .icon.spinning {
  animation-name: spin;
  animation-duration: 500ms;
  animation-iteration-count: infinite;
  animation-timing-function: linear;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.outcome {
  grid-area: outcome;
  box-sizing: content-box;
  border-radius: 5px;
  padding: 1rem;
  transition-property: background-color;
  transition-duration: 500ms;
  transition-delay: 0;
}

.outcome.win {
  background-color: green;
}
.outcome.draw {
  background-color: gray;
}
.outcome.lose {
  background-color: red;
}

.game {
  display: grid;
  height: 100%;
  grid-template-columns: 30% auto 20% auto 20% auto 30%;
  grid-template-rows: 5rem auto auto;
  grid-template-areas:
    ". . header header header . banker"
    "bet . wheel wheel wheel . banker"
    "balance . outcome outcome outcome . standing";
}


import React, {
  useState,
  useEffect,
  useRef,
} from "https://cdn.skypack.dev/react@17.0.1";
import ReactDOM from "https://cdn.skypack.dev/react-dom@17.0.1";

const LOAN_RATE_PER_BET = 0.02;
const LOAN_AMOUNT = 100;

function Game() {
  const [balance, setBalance] = useState(0);
  const [loanBalance, setLoanBalance] = useState(0);
  const standing = balance - loanBalance;

  const [isSpinning, setIsSpinning] = useState(false);
  const [bet, setBet] = useState(0);
  const [result, setResult] = useState(null);

  function borrow(amount) {
    setLoanBalance(Math.round((loanBalance + amount) * 100) / 100);
    setBalance(Math.round((balance + amount) * 100) / 100);
  }

  function placeBet(amount) {
    console.debug("bet", amount);
    setBalance(balance - amount);
    setLoanBalance(loanBalance * (1 + LOAN_RATE_PER_BET));
    setResult(null);
    setBet(amount);
    setIsSpinning(true);
  }

  function wheelStopped(result, bet, balance) {
    const multiplier = {
      win: 2,
      draw: 1,
      lose: 0,
    }[result];

    setIsSpinning(false);
    setResult(result);
    setBalance(balance + multiplier * bet);
  }

  return (
    <div className="game">
      <Balance className="balance" balance={balance} />
      <Standing className="standing" standing={standing} />
      <Bet
        className="bet"
        maxBetAmount={balance}
        onBet={(amount) => placeBet(amount)}
        bettingLocked={isSpinning}
      />
      <Banker
        className="banker"
        onBorrow={(amount) => borrow(amount)}
        onRepay={(amount) => borrow(-amount)}
        loanBalance={loanBalance}
        maxRepaymentAmount={balance}
        loanAmount={LOAN_AMOUNT}
        loanRate={LOAN_RATE_PER_BET}
      />
      <Wheel
        className="wheel"
        isSpinning={isSpinning}
        onStop={(result) => wheelStopped(result, bet, balance)}
      />
      <Outcome
        className="outcome"
        isSpinning={isSpinning}
        bet={bet}
        result={result}
      />

      <header>
        <h1>Spin the Wheel</h1>
      </header>
    </div>
  );
}

function Balance({ className, balance }) {
  return (
    <div className={className}>
      <h2>⚖️ Balance</h2>
      <figure>{balance.toFixed(2)}</figure>
    </div>
  );
}

function Standing({ className, standing }) {
  return (
    <div className={className}>
      <h2>Current Standing</h2>
      <figure>
        {standing < 0
          ? `(${Math.abs(standing.toFixed(2))})`
          : standing.toFixed(2)}
      </figure>
      <p>{standing < 0 && "Looks like you owe the banker…"}</p>
    </div>
  );
}

function Bet({ className, maxBetAmount, onBet, bettingLocked }) {
  const [bet, setBet] = useState(maxBetAmount);
  const validBet = 0 < bet && bet <= maxBetAmount;

  return (
    <div className={className}>
      <h2>🎟️ Bet</h2>
      {maxBetAmount > 0 ? (
        <>
          <p>
            You could double your money!{" "}
            <small>
              Or break even. <small>or lose it…</small>
            </small>
          </p>
          {bettingLocked ? (
            <>
              <p>Alright, let's see if you won!</p>
            </>
          ) : (
            <>
              {" "}
              <p>How much will you bet?</p>
              <label htmlFor="bet">Bet</label>
              <input
                id="bet"
                type="number"
                value={bet}
                onChange={(ev) => setBet(parseFloat(ev.currentTarget.value))}
              />
              <button disabled={!validBet} onClick={() => onBet(bet)}>
                Place bet 🎉
              </button>
            </>
          )}
        </>
      ) : (
        <p>
          Looks like you need some money. Talk to the banker before you try your
          luck here.
        </p>
      )}
    </div>
  );
}

function Wheel({
  className,
  isSpinning = false,
  spinDurationMs = 1500,
  onStop,
  winProbability = 3,
  drawProbability = 7,
  loseProbability = 1,
}) {
  const timeoutRef = useRef(null);
  useEffect(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    if (isSpinning) {
      timeoutRef.current = setTimeout(() => {
        const result =
          Math.random() * (winProbability + drawProbability + loseProbability);
        if (result <= winProbability) {
          onStop("win");
        } else if (result <= drawProbability) {
          onStop("draw");
        } else {
          onStop("lose");
        }
      }, spinDurationMs);
    }
  }, [isSpinning, spinDurationMs, timeoutRef]);
  return (
    <div className={className}>
      <div className={`icon ${isSpinning && "spinning"}`}>⚙️</div>
    </div>
  );
}

function Outcome({ className, isSpinning, bet, result }) {
  let content;
  switch (result) {
    case "win":
      content = <p>You won! 🎉</p>;
      break;
    case "draw":
      content = <p>Draw! You get your money back.</p>;
      break;
    case "lose":
      content = <p>Lose! Tough break. Try again.</p>;
      break;
    default:
      content = <p>You have to play to win. Place a bet!</p>;
      break;
  }
  return (
    <div className={`outcome ${result}`}>
      <h2>🎰 Outcome</h2>
      {isSpinning ? (
        <>
          <p>You bet {bet}</p>
          <p>⏱️ Waiting for the result</p>
        </>
      ) : (
        content
      )}
    </div>
  );
}

function Banker({
  className,
  onBorrow,
  onRepay,
  loanBalance,
  maxRepaymentAmount,
  loanAmount,
  loanRate,
}) {
  const [repaymentAmount, setRepaymentAmount] = useState(
    Math.min(maxRepaymentAmount, loanBalance)
  );
  const validRepayment =
    0 < repaymentAmount &&
    repaymentAmount <= Math.min(maxRepaymentAmount, loanBalance);
  return (
    <div className={className}>
      <h2>🏦 Banker</h2>
      <p>
        Want a loan? It's only {loanRate * 100}% interest{" "}
        <small>per round of betting…</small>
      </p>
      <button onClick={() => onBorrow(loanAmount)}>
        Sure, give me {loanAmount}
      </button>
      {loanBalance > 0 && (
        <>
          <p>
            You owe the bank {loanBalance.toFixed(2)}. You can pay back up to{" "}
            {Math.min(maxRepaymentAmount, loanBalance).toFixed(2)}.
          </p>
          <label htmlFor="repayment">Repayment amount</label>
          <input
            type="number"
            step="1"
            id="repayment"
            value={repaymentAmount}
            onChange={(ev) =>
              setRepaymentAmount(parseFloat(ev.currentTarget.value))
            }
          />
          <button
            disabled={!validRepayment}
            onClick={() => onRepay(repaymentAmount)}
          >
            Pay {repaymentAmount.toFixed(2)}
          </button>
        </>
      )}
    </div>
  );
}

ReactDOM.render(<Game />, document.getElementById("root"));

All of the rules of the game are contained in <Game/>. Particularly, borrow(), placeBet(), and wheelStopped(). This component is responsible for feeding data into the other components to display to the player.


  

  

function Game() {
  const [balance, setBalance] = useState(0);
  const [loanBalance, setLoanBalance] = useState(0);
  const standing = balance - loanBalance;

  const [isSpinning, setIsSpinning] = useState(false);
  const [bet, setBet] = useState(0);
  const [result, setResult] = useState(null);

  function borrow(amount) {
    setLoanBalance(Math.round((loanBalance + amount) * 100) / 100);
    setBalance(Math.round((balance + amount) * 100) / 100);
  }

  function placeBet(amount) {
    console.debug("bet", amount);
    setBalance(balance - amount);
    setLoanBalance(loanBalance * (1 + LOAN_RATE_PER_BET));
    setResult(null);
    setBet(amount);
    setIsSpinning(true);
  }

  function wheelStopped(result, bet, balance) {
    const multiplier = {
      win: 2,
      draw: 1,
      lose: 0,
    }[result];

    setIsSpinning(false);
    setResult(result);
    setBalance(balance + multiplier * bet);
  }

  return (
    <div className="game">
      <Balance className="balance" balance={balance} />
      <Standing className="standing" standing={standing} />
      <Bet
        className="bet"
        maxBetAmount={balance}
        onBet={(amount) => placeBet(amount)}
        bettingLocked={isSpinning}
      />
      <Banker
        className="banker"
        onBorrow={(amount) => borrow(amount)}
        onRepay={(amount) => borrow(-amount)}
        loanBalance={loanBalance}
        maxRepaymentAmount={balance}
        loanAmount={LOAN_AMOUNT}
        loanRate={LOAN_RATE_PER_BET}
      />
      <Wheel
        className="wheel"
        isSpinning={isSpinning}
        onStop={(result) => wheelStopped(result, bet, balance)}
      />
      <Outcome
        className="outcome"
        isSpinning={isSpinning}
        bet={bet}
        result={result}
      />

      <header>
        <h1>Spin the Wheel</h1>
      </header>
    </div>
  );
}

The <Balance/> and <Standing/> components are very simple displays of the numbers they’re given.


  

  

function Balance({ className, balance }) {
  return (
    <div className={className}>
      <h2>⚖️ Balance</h2>
      <figure>{balance.toFixed(2)}</figure>
    </div>
  );
}

function Standing({ className, standing }) {
  return (
    <div className={className}>
      <h2>Current Standing</h2>
      <figure>
        {standing < 0
          ? `(${Math.abs(standing.toFixed(2))})`
          : standing.toFixed(2)}
      </figure>
      <p>{standing < 0 && "Looks like you owe the banker…"}</p>
    </div>
  );
}

The betting interactions happen among the <Bet/>, <Wheel/>, and <Outcome/>.


  

  

function Bet({ className, maxBetAmount, onBet, bettingLocked }) {
  const [bet, setBet] = useState(maxBetAmount);
  const validBet = 0 < bet && bet <= maxBetAmount;

  return (
    <div className={className}>
      <h2>🎟️ Bet</h2>
      {maxBetAmount > 0 ? (
        <>
          <p>
            You could double your money!{" "}
            <small>
              Or break even. <small>or lose it…</small>
            </small>
          </p>
          {bettingLocked ? (
            <>
              <p>Alright, let's see if you won!</p>
            </>
          ) : (
            <>
              {" "}
              <p>How much will you bet?</p>
              <label htmlFor="bet">Bet</label>
              <input
                id="bet"
                type="number"
                value={bet}
                onChange={(ev) => setBet(parseFloat(ev.currentTarget.value))}
              />
              <button disabled={!validBet} onClick={() => onBet(bet)}>
                Place bet 🎉
              </button>
            </>
          )}
        </>
      ) : (
        <p>
          Looks like you need some money. Talk to the banker before you try your
          luck here.
        </p>
      )}
    </div>
  );
}

function Wheel({
  className,
  isSpinning = false,
  spinDurationMs = 1500,
  onStop,
  winProbability = 3,
  drawProbability = 7,
  loseProbability = 1,
}) {
  const timeoutRef = useRef(null);
  useEffect(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    if (isSpinning) {
      timeoutRef.current = setTimeout(() => {
        const result =
          Math.random() * (winProbability + drawProbability + loseProbability);
        if (result <= winProbability) {
          onStop("win");
        } else if (result <= drawProbability) {
          onStop("draw");
        } else {
          onStop("lose");
        }
      }, spinDurationMs);
    }
  }, [isSpinning, spinDurationMs, timeoutRef]);
  return (
    <div className={className}>
      <div className={`icon ${isSpinning && "spinning"}`}>⚙️</div>
    </div>
  );
}

function Outcome({ className, isSpinning, bet, result }) {
  let content;
  switch (result) {
    case "win":
      content = <p>You won! 🎉</p>;
      break;
    case "draw":
      content = <p>Draw! You get your money back.</p>;
      break;
    case "lose":
      content = <p>Lose! Tough break. Try again.</p>;
      break;
    default:
      content = <p>You have to play to win. Place a bet!</p>;
      break;
  }
  return (
    <div className={`outcome ${result}`}>
      <h2>🎰 Outcome</h2>
      {isSpinning ? (
        <>
          <p>You bet {bet}</p>
          <p>⏱️ Waiting for the result</p>
        </>
      ) : (
        content
      )}
    </div>
  );
}

And finally, the interaction with the <Banker/> gives the player the leverage to start betting. (Whether that’s a good idea is left entirely to the player…)


  

  

function Banker({
  className,
  onBorrow,
  onRepay,
  loanBalance,
  maxRepaymentAmount,
  loanAmount,
  loanRate,
}) {
  const [repaymentAmount, setRepaymentAmount] = useState(
    Math.min(maxRepaymentAmount, loanBalance)
  );
  const validRepayment =
    0 < repaymentAmount &&
    repaymentAmount <= Math.min(maxRepaymentAmount, loanBalance);
  return (
    <div className={className}>
      <h2>🏦 Banker</h2>
      <p>
        Want a loan? It's only {loanRate * 100}% interest{" "}
        <small>per round of betting…</small>
      </p>
      <button onClick={() => onBorrow(loanAmount)}>
        Sure, give me {loanAmount}
      </button>
      {loanBalance > 0 && (
        <>
          <p>
            You owe the bank {loanBalance.toFixed(2)}. You can pay back up to{" "}
            {Math.min(maxRepaymentAmount, loanBalance).toFixed(2)}.
          </p>
          <label htmlFor="repayment">Repayment amount</label>
          <input
            type="number"
            step="1"
            id="repayment"
            value={repaymentAmount}
            onChange={(ev) =>
              setRepaymentAmount(parseFloat(ev.currentTarget.value))
            }
          />
          <button
            disabled={!validRepayment}
            onClick={() => onRepay(repaymentAmount)}
          >
            Pay {repaymentAmount.toFixed(2)}
          </button>
        </>
      )}
    </div>
  );
}

See also