seamsay.xyz Musings on science, software, and cooking.

The Maths Of Play Your Cards Right

Some friends and I do a pub quiz every week (hosted by someone that beat Mark Labett on The Chase, not to brag...) which always ends with a round of Play Your Cards Right. One week my friend asked whether choosing aces to be high or low affected your chances of winning. My gut feeling was that it only affects your chance of winning the first round and that after that it is simply a relabelling of the distribution. And while this is true, our process of convincing ourselves led to some interesting discoveries of the maths of this game.

Optimal Strategies

Switch?

Card P(further) P(equidistance) P(closer)
A \(0\) \(\frac{3}{51}\) \(\frac{48}{51}\)
2/K \(\frac{4}{51}\) \(\frac{7}{51}\) \(\frac{40}{51}\)
3/Q \(\frac{12}{51}\) \(\frac{7}{51}\) \(\frac{32}{51}\)
4/J \(\frac{20}{51}\) \(\frac{7}{51}\) \(\frac{24}{51}\)
5/10 \(\frac{28}{51}\) \(\frac{7}{51}\) \(\frac{16}{51}\)
6/9 \(\frac{36}{51}\) \(\frac{7}{51}\) \(\frac{8}{51}\)
7/8 \(\frac{44}{51}\) \(\frac{7}{51}\) \(0\)
Card P(further) P(equidistance) P(closer)
A \(0\) \(\frac{3}{51}\) \(\frac{44}{51}\)
2/Q \(\frac{4}{51}\) \(\frac{7}{51}\) \(\frac{36}{51}\)
3/J \(\frac{12}{51}\) \(\frac{7}{51}\) \(\frac{28}{51}\)
4/10 \(\frac{20}{51}\) \(\frac{7}{51}\) \(\frac{20}{51}\)
5/9 \(\frac{28}{51}\) \(\frac{7}{51}\) \(\frac{12}{51}\)
6/8 \(\frac{36}{51}\) \(\frac{7}{51}\) \(\frac{4}{51}\)
7 \(\frac{44}{51}\) \(\frac{3}{51}\) \(0\)

Aces High?

Higher Or Lower?

A Monte Carlo Simulation

const LABELS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
const CARDS_PER_LABEL = 4
const ROUNDS = 5

@enum Result WRONG_CHOICE EQUAL_CARD WIN

struct Outcome{L}
    result::Result
    final_round::Int
    initial_card::L
    starting_card::L
    aces_high::Bool
end

function random_card!(deck)
    index = rand(only(axes(deck)))
    card = deck[index]

    deleteat!(deck, index)

    card
end

function simulate(
        card_labels::Vector,
        n::Int,
        rounds::Int,
        switch_strategy::Function,
        aces_strategy::Function,
        higher_strategy::Function,
)
    labels = convert(Vector{Union{Nothing,eltype(card_labels)}}, copy(card_labels))
    deck = repeat(only(axes(labels)), inner=n)

    initial = start = random_card!(deck)
    if switch_strategy(start, deck)
        start = random_card!(deck)
    end

    aces_high = aces_strategy(start, deck)
    if aces_high
        push!(labels, first(labels))
        labels[begin] = nothing

        for i in only(axes(deck))
            if deck[i] == firstindex(labels)
                deck[i] = lastindex(labels)
            end
        end

        if start == firstindex(labels)
            start = lastindex(labels)
        end

        if initial == firstindex(labels)
            initial = lastindex(labels)
        end
    end

    card = start
    for round in 1:rounds
        higher = higher_strategy(card, deck)
        next = random_card!(deck)

        if card == next
            return Outcome(EQUAL_CARD, round, labels[initial], labels[start], aces_high)
        elseif (higher && next < card) || (!higher && next > card)
            return Outcome(WRONG_CHOICE, round, labels[initial], labels[start], aces_high)
        end

        card = next
    end

    Outcome(WIN, rounds, labels[initial], labels[start], aces_high)
end

run(trials, labels, n, rounds, switch_strat, aces_strat, higher_strat) = [
    simulate(labels, n, rounds, switch_strat, aces_strat, higher_strat)
    for _ in 1:trials
]

trials = 999999
switch_random(_, _) = rand(Bool)
aces_random(_, _) = rand(Bool)
higher_random(_, _) = rand(Bool)

random_outcomes = run(
    trials,
    LABELS,
    CARDS_PER_LABEL,
    ROUNDS,
    switch_random,
    aces_random,
    higher_random,
)

random_avg = sum(
    outcome.result == WIN
    for outcome in random_outcomes
) / trials

# All samples are either 0 or 1, so E[X^2] == E[X].
random_std = sqrt(random_avg - random_avg^2)

random_avg, random_std
(0.022834022834022832, 0.14937412839992792)
switch_never(_, _) = false
aces_always_low(_, _) = false
aces_always_high(_, _) = true
higher_if_lower_than_7(card, _) = card < 7
higher_if_lower_than_8(card, _) = card < 8

simple_aces_low_outcomes = run(
    trials,
    LABELS,
    CARDS_PER_LABEL,
    ROUNDS,
    switch_never,
    aces_always_low,
    higher_if_lower_than_7,
)

simple_aces_low_avg = sum(
    outcome.result == WIN
    for outcome in simple_aces_low_outcomes
) / trials

simple_aces_low_std = sqrt(simple_aces_low_avg - simple_aces_low_avg^2)

simple_aces_high_outcomes = run(
    trials,
    LABELS,
    CARDS_PER_LABEL,
    ROUNDS,
    switch_never,
    aces_always_high,
    higher_if_lower_than_8,
)

simple_aces_high_avg = sum(
    outcome.result == WIN
    for outcome in simple_aces_high_outcomes
) / trials

simple_aces_high_std = sqrt(simple_aces_high_avg - simple_aces_high_avg^2)
simple_aces_low_avg, simple_aces_low_std
(0.16833116833116832, 0.3741601075735255)
simple_aces_high_avg, simple_aces_high_std
(0.16897116897116898, 0.374726450931457)
function switch_if_close_to_midpoint(initial, deck)
    wo_ace = filter(!=(1), deck)

    # By calculating the midpoint without aces we can match cards by how close to
    # the middle of the pack they are by taking away the midpoint (meaning the
    # middle of the pack will be zero) and taking the absolute value (meaning cards
    # that are equally close to the middle will have the same number).

    # Aces are accounted for naturally by this as they will become the highest with
    # no matching cards.

    # Even numbered packs are also accounted for naturally as only the 7s will be 0
    # (as opposed to 7s and 8s being 0.5).

    # It's important we use the midpoint here, as we want to match cards by _value_
    # we don't care how many there are in the pack (as only the current card has)
    # been drawn.
    mid = (minimum(wo_ace) + maximum(wo_ace)) / 2

    matched = abs.(deck .- mid)
    card = abs(initial - mid)

    sum(matched .< card) < sum(matched .> card)
end

function aces_high_if_more_high_cards(card, deck)
    wo_ace = filter(!=(1), deck)

    # Maximise the chance of winning the first round by ensuring there are more
    # cards on the side that you will pick. Specifically this means that we go aces
    # high if there are fewer cards lower than the initial card and low otherwise.
    lower = sum(wo_ace .< card)
    higher = sum(wo_ace .> card)

    if lower == higher
        rand(Bool)
    else
        lower < higher
    end
end

function high_if_more_high_cards(card, deck)
    lower = sum(deck .< card)
    higher = sum(deck .> card)

    if lower == higher
        rand(Bool)
    else
        lower < higher
    end
end

counting_outcomes = run(
    trials,
    LABELS,
    CARDS_PER_LABEL,
    ROUNDS,
    switch_if_close_to_midpoint,
    aces_high_if_more_high_cards,
    high_if_more_high_cards,
)

counting_avg = sum(outcome.result == WIN for outcome in counting_outcomes) / trials
counting_std = sqrt(counting_avg - counting_avg^2)

counting_avg, counting_std
(0.1931131931131931, 0.39474103885816036)

Random Probability Of Winning

Maximal Probability Of Winning (Assuming The Above Strategies Work)

How Many Rounds Will You Survive For Any Given Starting Card?

Probability Of Winning For Any Given Starting Card

Probability Of Losing On Equality For Any Given Starting Card

Conclusion

Future Questions

  • Are these strategies actually optimal? Can we learn more optimal strategies?
  • Can we analytically calculate the total number of non-degenerate game states?
  • Can if we reduced the number of cards? Is it analytically tractable?