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.
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\) |
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)
- 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?