Log in to start. Pick any name — it's how your progress and your chat with Smee are kept.
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.
Those four ideas are the whole game. Everything below is you building them one at a time.
Work top to bottom. Each station is a new part of the program with:
TODO for you. Hit Copy and fill in the blanks.💬 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.
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.
# 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.
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.
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.
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).
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.
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.
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.
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.
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.
# "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.
value dictionary to look up what a "K" is worthMission: get a working Python set-up and one file to grow all week.
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.
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})')
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.
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.)
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.
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)
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.
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.
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.
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
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.
Paste equity() plus the functions it leans on — make_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.)
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.
# 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.
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.
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.
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.
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.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.
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.
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.)
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.
• 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.
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.