CARD SHARK

CARD SHARK

Build an engine that knows the odds — and how to bet them.
0 / 0 done

The mission

You'll build one program, growing it piece by piece, that can do three things: work out the odds in a game of cards, spot when those odds are in your favour, and decide how much to bet when they are.

The games are poker and blackjack. By the end you'll have code that can tell you the real odds of a poker hand, find a genuine edge against a casino at the blackjack table, and work out the exact bet size that grows your money fastest without going broke. The same three ideas quietly run casinos, betting markets and trading floors — but cards are where you can actually watch them work.

It's one codebase the whole week. Nothing you build gets thrown away — the deck you write on Monday is still running on Friday. How far down the road you get is up to you; there's no minimum and no ceiling. Keep walking.

Expected valueWhat's a bet worth, on average?
ProbabilityHow often does each thing actually happen?
EdgeWhen are the odds tilted in your favour?
SizingGiven an edge, how much do you bet?

Those four ideas are the whole game. Everything below is you building them one at a time.

How to use this site

Work top to bottom. Each station is a new part of the program with:

  • The mission — what to build.
  • The maths — the idea you're really learning.
  • A checklist — tick things off; your progress saves automatically in this browser.
  • Starter code — a scaffold with the boring bits done and the interesting bits left as TODO for you. Hit Copy and fill in the blanks.
  • Hints — open only if you're stuck. Try first.
  • A milestone — how you know that part actually works before moving on.

💬 Stuck, or want something explained? Tap the 👾 Ask Smee button in the bottom-right corner — any time, on any station. It opens a chat with me (Smee); I know this whole project and I'm happy to talk through the Python, the maths, or whatever bug is biting. Hit the to tuck it back into the corner, and your conversation is still there when you reopen it.

Golden rule: never trust a number you haven't tested. Whenever you can work something out by hand and by simulation, do both and check they agree. When they don't, one of them is wrong — and finding out which is where the real learning is.

Python warm-up

Optional — the handful of Python this project actually needs.
This whole station is optional. Brand new to Python? Spend an hour or two here first — it covers the small set of things the rest of the project leans on, using cards as the examples. Already happy writing loops and functions? Skip straight to Station 0 →

Make a scratch file called warmup.py, copy the bits below into it, and run it with python3 warmup.py. Change things, break things, see what happens. Nothing here is precious — it's a sandbox.

1 · Run something
# warmup.py  —  run it in the terminal with:  python3 warmup.py
print("hello, table")
print("2 + 2 =", 2 + 2)

print(...) shows things on screen. It's how you'll check everything works as you go.

2 · Variables & types
chips   = 100        # a whole number (int)
edge    = 0.015      # a decimal (float)
rank    = "A"        # text (a string)
in_play = True       # a yes/no value (bool): True or False

print(rank, chips, in_play)

A variable is just a name for a value. You make one with =. The four types above are basically everything you'll use.

3 · Lists — your deck and your hand
hand = ["As", "Kd", "Qh", "Jc", "Ts"]   # a list of 5 cards

print(hand[0])     # first card  -> As   (counting starts at 0!)
print(hand[-1])    # last card   -> Ts
print(len(hand))   # how many    -> 5
print(hand[1:3])   # a "slice"   -> ['Kd', 'Qh']

hand.append("9d")   # add a card to the end

A list holds things in order. A whole deck of cards is just a list of 52 of these strings — that's all it is.

4 · Looping over cards
for card in hand:
    print("you hold", card)

for i in range(3):    # gives 0, 1, 2
    print("deal number", i)

A for loop does something once for each item. The indented lines are "inside" the loop — indentation is how Python groups code, so keep it consistent (4 spaces).

5 · Making decisions (if / else)
total = 19

if total > 21:
    print("bust!")
elif total == 21:        # note: == tests equality, = assigns
    print("twenty-one!")
else:
    print("you have", total)

> < == != compare two things and give back True or False. The block under the first true test runs.

6 · Functions — reusable bits
def double(stake):
    return stake * 2

print(double(50))      # -> 100

def greet(name="player"):   # name has a default value
    return "hi " + name

print(greet())         # -> hi player
print(greet("Kate"))   # -> hi Kate

A function is a named recipe. You define it once with inputs, and return a result. The entire project is you writing functions like this and joining them up.

7 · Dictionaries — quick look-ups
value = {"J": 11, "Q": 12, "K": 13, "A": 14}

print(value["K"])     # -> 13
print(value["A"])     # -> 14

A dictionary maps a key to a value — perfect for "what's this card worth?". You'll use exactly this to score hands.

8 · Printing nicely (f-strings)
edge = 0.0234

print(f"the edge is {edge:.2%}")   # -> the edge is 2.34%
print(f"you have {chips} chips")     # slot a variable straight in

Put f before a string and you can drop variables inside { }. The :.2% formats a decimal as a percentage with 2 places. Handy for reporting results.

9 · Borrowing tools (import)
import random

print(random.randint(1, 6))   # a random die roll, 1 to 6
random.shuffle(hand)          # shuffles your list in place
print(hand)

Python comes with toolboxes you "import". random (dice, shuffling) and itertools (combinations) both show up later.

10 · The one-line list (comprehensions)
# "build a list by looping, in one line"
squares = [n * n for n in range(5)]     # -> [0, 1, 4, 9, 16]

# a whole deck is built exactly this way — every rank with every suit:
deck = [r + s for r in "AKQJT98765432" for s in "cdhs"]
print(len(deck))   # -> 52

This looks odd at first but it's everywhere in the scaffolds. Read it right-to-left: "loop over these, and collect r + s into a list." When you meet make_deck() in Station 1, this is the trick it uses.

You're ready for Station 0 when you can, without much help:
  • print every card in a hand on its own line (loop)
  • write a function that takes a number and returns double it
  • use the value dictionary to look up what a "K" is worth
Don't aim to memorise any of this — just know it exists and where to look it up. You'll learn the rest by using it.
0

Setting up

Get Python ready and lay out the table.

Mission: get a working Python set-up and one file to grow all week.

Why no notebooks: write real functions in a real file and run it from the terminal. It's a habit worth building, and it's what makes the later stations possible.
1

The table & the EV machine

A deck of cards and a way to measure any bet.

Mission: build a deck of cards, and a simulation harness — a function that plays any gambling game tens of thousands of times and reports what it's worth.

The maths — Expected Value & the Law of Large Numbers. The EV of a bet is the average profit if you played it forever. You can't play forever, but if you simulate it 100,000 times the average creeps very close to the true EV. Watch it happen and you'll never forget it.
Starter code — copy this in
import random

RANKS = '23456789TJQKA'   # T = ten
SUITS = 'cdhs'            # clubs diamonds hearts spades

def make_deck():
    # a card is a 2-char string like 'As' (ace of spades) or 'Td'
    return [r + s for r in RANKS for s in SUITS]

def simulate(trial_fn, n=100_000):
    """Run trial_fn() n times (each call returns the profit from one play).
    Return a TUPLE of two summary numbers about those n results: one that
    captures what the bet is worth on average, and one for how much it swings.
    (numpy might be helpful.)"""
    results = [trial_fn() for _ in range(n)]
    # TODO: build and return that tuple
    ...

def dice_game():
    """You stake nothing extra; you just play. Roll one die.
    A six pays you +4. Anything else costs you -1.
    Good bet or bad bet? Work out the EV on paper FIRST."""
    roll = random.randint(1, 6)
    # TODO: return your net profit for this single roll
    ...

if __name__ == '__main__':
    ev, sd = simulate(dice_game)
    print(f'dice game EV = {ev:.4f}  (std {sd:.2f})')
Hints

EV by hand: one outcome in six pays +4, five in six pay −1. So EV = (1/6)(4) + (5/6)(−1). Is that positive or negative? Your simulator should land within a whisker of it.

For the convergence plot: keep a running total as you go, and at each step record total/count. Plot that list. You'll see wild swings early that settle into a flat line — that flat line is the EV.

✅ Test bench — check your EV machine

Paste your make_deck(), dice_game() and simulate() below and hit Run checks. It runs them right here in the browser. (The EV checks roll the dice tens of thousands of times, so a tiny wobble is fine — they allow a sensible margin.)

🏁
Milestone: your simulator's EV for the dice game matches your hand calculation to two decimal places. You now have a machine that can price any bet you can describe in code.
2

Reading a hand

Teach the computer what beats what.

Mission: write a hand evaluator — give it five cards and it tells you how strong they are, so you can compare any two hands and say who wins.

The maths — turning rules into a number you can compare. A poker hand is really a ranking: pair beats high card, flush beats straight, and so on. The trick is to turn each hand into a single comparable value so the computer can sort them. This is the chewy programming heart of the week — everything later plugs into it.
Starter code — copy this in
from collections import Counter

RANK_VALUE = {r: i for i, r in enumerate(RANKS, start=2)}  # '2'->2 ... 'A'->14

def hand_rank(cards):
    """cards: list of 5 strings e.g. ['As','Ks','Qs','Js','Ts'].
    Return a tuple (category, tiebreakers...) so that comparing two
    tuples with > tells you which hand wins.
    Categories, low to high:
      0 high card   1 pair      2 two pair   3 trips
      4 straight    5 flush     6 full house 7 quads   8 straight flush"""
    values = sorted((RANK_VALUE[c[0]] for c in cards), reverse=True)
    suits  = [c[1] for c in cards]
    counts = Counter(values)                # how many of each rank
    is_flush = len(set(suits)) == 1

    # TODO: is_straight? remember the wheel A-2-3-4-5 (treat the ace as 1)
    # TODO: use counts to spot pairs / trips / quads
    # TODO: build and return the comparable tuple, e.g.
    #       a pair of kings might be (1, 13, [kicker, kicker, kicker])
    ...

def beats(hand_a, hand_b):
    return hand_rank(hand_a) > hand_rank(hand_b)
Hints

Counter(values) gives you something like {13:2, 9:1, 6:1, 4:1} for a pair of kings. The shape of those counts tells you the category: [2,1,1,1] is a pair, [3,2] is a full house, [4,1] is quads. Sort the counts to recognise the pattern.

For tie-breaks, list the ranks in the order that matters: for a full house it's (trip rank, pair rank); for high card it's all five descending. Put the category first in the tuple and Python compares the rest automatically.

The wheel: if the sorted values are [14,5,4,3,2], that's a straight to the five — treat it as if the ace were a 1.

✅ Test bench — check your hand evaluator

Paste your hand_rank(cards) (plus any helper functions you wrote) below and hit Run checks. It runs real hands through your code, right here in the browser — no setup needed. Green means it works; red tells you exactly which case is wrong. Keep writing the real thing in your editor — this is just a quick way to test it.

🏁
Milestone: you can hand it a royal flush and a pair of twos and it always picks the right winner — including the fiddly cases. (The test bench above is the quick way to know.) You now have the engine the rest of the week is built on.
3

What are the odds?

A poker equity calculator.

Mission: work out your chance of winning a poker hand by Monte Carlo — deal the unknown cards thousands of times and count how often you win.

The maths — estimating a probability you can't easily calculate. The exact odds of a Texas Hold'em hand are a nightmare to compute by hand. So we don't. We deal it out 20,000 times and count. This "simulate it lots and count" trick is one of the most powerful tools there is — it's how you get at numbers that no neat formula will give you.
Starter code — copy this in
from itertools import combinations
import random

def best_of_seven(seven):
    """seven: list of 7 cards. Return the best hand_rank over all
    5-card combinations."""
    return max(hand_rank(list(five)) for five in combinations(seven, 5))

def equity(hero, villain=None, board=None, n=20_000):
    """hero: your 2 cards e.g. ['As','Ad'].
    villain: their 2 cards, or None to give them random cards each time.
    board: known community cards so far (0 to 5 of them), or None.
    Return your probability of winning (ties count as half)."""
    board = board or []
    known = set(hero) + set(board) + (set(villain) if villain else set())
    wins = 0.0
    for _ in range(n):
        deck = [c for c in make_deck() if c not in known]
        random.shuffle(deck)
        # TODO: deal villain 2 cards if they were None
        # TODO: deal out the rest of the board until there are 5
        # TODO: score best_of_seven for both, add 1 for a win, 0.5 for a tie
        ...
    return wins / n
Hints

Watch out: set(hero) + set(...) won't work — sets don't add with +. Use set(hero) | set(board) | ... (union) instead. (Left it slightly wrong on purpose — spotting bugs is the job.)

Pull villain's cards and the remaining board off the top of the shuffled deck with slicing: deck[:2], then deck[2:2+needed].

If 20,000 trials is slow, that's fine for now — make it correct first. Speed is a stretch goal.

✅ Test bench — check your equity calculator

Paste equity() plus the functions it leans onmake_deck(), hand_rank() and best_of_seven() — then hit Run checks. It deals a few hundred hands per check, so give it a few seconds. (Monte Carlo wobbles, so the checks allow a margin.)

🏁
Milestone: type in any two hands and your code tells you the odds, matching the known poker numbers. That's a real, genuinely useful tool — most poker players would love to have it.
4

Beating the house

Blackjack, the house edge, and card counting.

Mission: switch games to blackjack. First prove the casino always wins long-term — then find the crack in the wall that flips the edge to you.

The maths — edge, and where it hides. Every casino game has a built-in house edge — a tiny negative EV that guarantees they win over millions of hands. Blackjack is special: the edge isn't fixed. When lots of high cards remain in the deck, the odds swing in the player's favour. Card counting is just tracking that swing. You'll see a negative-EV game turn positive in front of you.
Starter code — copy this in
# Blackjack is bigger than the earlier stations — build it in small pieces.
# Card values: 2-9 face value, T/J/Q/K = 10, A = 11 or 1 (a "soft" ace).

def hand_value(cards):
    """Return the best total <= 21 if possible, treating aces as 11 then
    dropping to 1 as needed."""
    # TODO: total the cards; for each ace, use 11 unless it busts you
    ...

HI_LO = {'2':1,'3':1,'4':1,'5':1,'6':1,
         '7':0,'8':0,'9':0,
         'T':-1,'J':-1,'Q':-1,'K':-1,'A':-1}

def play_hand(shoe, running_count):
    """Deal one round from `shoe` (a list of cards you pop from).
    Player follows basic strategy; dealer hits until 17.
    Return (profit_in_units, new_running_count)."""
    # TODO: deal 2 to player, 2 to dealer (one shown)
    # TODO: player hits/stands by a simple basic-strategy rule
    # TODO: dealer plays; compare; return profit (+1 win, -1 loss, 0 push)
    # TODO: update running_count with HI_LO for every card seen
    ...

# "true count" = running_count / decks_remaining  — this is what predicts EV.
Hints

Start with the dumbest possible basic strategy: "hit until 17 or more, then stand." Measure the edge. Then improve the rule (e.g. stand on 12+ when the dealer shows a weak card) and watch the edge shrink toward −0.5%.

To see counting work: bucket every hand by its true count at the moment of the bet, then average the profit in each bucket. Plot EV against true count — it should slope upward and cross zero. That crossing point is where you'd start betting big.

Use a multi-deck shoe (say 6 decks) and reshuffle when it gets low, like a real casino.

✅ Test bench — check your hand_value

The fiddly bit of blackjack is scoring a hand when an ace can be 11 or 1. Paste your hand_value(cards) below and hit Run checks to make sure the aces behave.

🏁
Milestone: your plot shows EV climbing with the count and turning positive at high counts. You've found, and proven with code, a real exploitable edge against a casino. This is the moment it all clicks.
5

How much do you bet?

The Kelly criterion, and the finished machine.

Mission: having an edge isn't enough — bet too big and you go broke even when you're right. Work out the optimal bet size, then bolt everything together into one bankroll-growing machine.

The maths — the Kelly criterion. There's a single best fraction of your money to bet given your edge and odds: f* = (b·p − q) / b, where p is your win chance, q = 1−p, and b is the net odds. Bet that fraction and your money grows fastest in the long run. Bet double it and — astonishingly — you go broke almost surely, even with the edge. Sizing matters as much as the edge itself. It's the lesson that separates people who win in the long run from people who go broke holding a winning hand.
Starter code — copy this in
import numpy as np

def kelly_fraction(p, b):
    """p = probability of winning. b = net odds (win b units per 1 staked).
    Return the fraction of your bankroll to wager.
    For an even-money bet (b = 1) this simplifies to f* = 2p - 1."""
    # TODO: implement f* = (b*p - (1-p)) / b
    ...

def grow_bankroll(p, b, fraction, start=1.0, bets=1000):
    """Play `bets` bets, each staking `fraction` of the CURRENT bankroll.
    Return the path of bankroll values so you can plot it."""
    money = start
    path = [money]
    for _ in range(bets):
        stake = money * fraction
        # TODO: win with prob p (money += stake*b) else lose (money -= stake)
        path.append(money)
    return path

# Compare: full Kelly, half stake, and DOUBLE Kelly. Plot all three on
# a log scale and watch which ones soar and which one dies.
Hints

Plot bankrolls on a log scale (plt.yscale('log')) — growth is multiplicative, so a log axis is the honest way to see it. Full Kelly is volatile but climbs; over-Kelly spikes then craters.

Risk of ruin: run the whole thing a few thousand times and count the fraction of runs where the bankroll drops below some "I'm out" threshold. That fraction is your risk of ruin — the number that tells you whether your betting plan is brave or just reckless.

For the final bot, reuse your Stage 4 count. Map true count → rough edge (a known rule of thumb: each +1 of true count is worth about +0.5% EV), feed that edge into Kelly, and let it size the bets itself.

✅ Test bench — check your Kelly formula

Paste your kelly_fraction(p, b) below and hit Run checks. These are exact — a correct formula nails every one. (Just the function; you don't need the bankroll simulation here.)

🏁
Milestone: one program that watches the cards, knows when it has an edge, sizes its bets by Kelly, and grows a bankroll it can defend with a risk-of-ruin number. That is, genuinely, a serious piece of work. Take a screenshot of that bankroll curve — you earned it.

How far you get

There's no pass mark and no failing. Wherever you stop, what you've built actually works and tells one clean story: find the edge, measure it, size it.

Some people will get a couple of stations in and have something they're genuinely proud of. Some will reach the end. Both are good — don't race. A solid, well-tested early station is worth far more than a half-broken later one. Go as far as the games pull you, and make each piece actually work before you move on.

Field notes

Habits that'll make you look like you've done this before

Test as you go. After every function, feed it something you already know the answer to. A function you haven't tested is a guess.

Hand AND machine. Whenever there's a number you can work out on paper, do — then check the simulation agrees. Disagreement means a bug, and bugs are where the learning is.

Small pieces. Don't write 200 lines then run it. Write five, run it, see it work, write five more.

More trials = less noise. If a simulated number jumps around each run, you haven't run enough trials. Roughly, four times the trials halves the wobble.

The one-page write-up (do this whatever tier you reach)

Finish with a single page: one chart, and three or four sentences on what you built and the most surprising thing you learned. Being able to explain a result clearly is half the work — arguably the more important half.