How I planned, built, tested and version-controlled a playable card game in about 300 lines of Python.

I wanted a first portfolio project that was small enough to finish but still had some real logic in it. A to-do list felt boring. A full web app felt like too much. Blackjack was a good middle ground: everyone knows the rules, it needs the player to make choices, and it has a couple of rules that are trickier to code than they look.

The result is a game you play in your terminal against a computer dealer. You bet chips, hit, stand or double down, and try to beat the dealer without going over 21.

👉 Code: github.com/rtfgbnjko/terminal-blackjackv2

A hand of blackjack being played in the terminal

Step 1: Brainstorming

Before writing any code I listed what the game had to do, then split it into "must have" and "nice to have".

Must have

A shuffled deck to deal from
A hand total that handles aces correctly
The player choosing to hit or stand using input()
A dealer who follows fixed rules
Working out who won

Nice to have

Betting with chips
Doubling down
Drawing the cards as ASCII art
Stats at the end of the session

I built the "must have" list first so I always had a working game, then added the extras one at a time.

Step 2: Splitting logic from input

The most useful decision I made was keeping the game rules apart from the terminal input and output. The top of blackjack.py has three classes that never call print() or input():

Card: a rank and a suit, plus its blackjack value
Shoe: several decks shuffled together (casinos usually use six)
Hand: the cards someone is holding, plus their total

Everything that talks to the player sits further down. That made the rules easy to test, because I could check a hand's value without typing anything into a prompt.

Step 3: The ace problem

An ace is worth 11, unless that would push you over 21, in which case it's worth 1. A hand can have several aces, and each one can flip separately.

My first attempt counted every ace as 1 and then tried to add 10 "if it fit". It worked until I got two aces. The cleaner way is the other way round: count every ace as 11, then while the hand is over 21 and there's still an ace counted as 11, take 10 off.

python
def _total_and_soft_aces(self):
total = sum(card.value for card in self.cards)
aces_as_eleven = sum(1 for card in self.cards if card.rank == "A")
while total > 21 and aces_as_eleven:
total -= 10
aces_as_eleven -= 1
return total, aces_as_eleven

The leftover count also says whether the hand is "soft" (an ace still counting as 11). A+6 is a soft 17. That matters because you can safely hit a soft hand.

Step 4: Making input() safe

input() gives back whatever the user typed, and people type all sorts of things. I wrapped it in a few small helpers:

python
def ask_choice(prompt, choices):
while True:
answer = ask(prompt)
if answer in choices:
return choices[answer]
print(" Sorry, I didn't get that. Try again.")

choices is a dictionary, so h and hit both mean "hit". The betting prompt checks that the bet is a whole number you can afford, and it also accepts all and q. Pressing Ctrl+C prints a goodbye message instead of a wall of error text.

Step 5: The dealer and the payouts

The dealer has no choices to make. They hit on 16 or less and stand on 17 or more, which is one line of code:

python
def dealer_should_hit(hand):
return hand.value < 17

Working out the winner is a list of checks, and the order matters. A player who busts loses even if the dealer busts afterwards. A dealer blackjack beats a player's 21 made with three cards. A natural blackjack pays 3 to 2, so a 10-chip bet wins 15.

Step 6: Testing

I wrote 20 unit tests with Python's built-in unittest. They cover the cases that are easy to get wrong: two aces (12), four aces (14), A+6+9 (16, not 26), blackjack against blackjack, and the dealer standing on a soft 17.

bash
python3 -m unittest -v

I also added a --seed option so the shuffle can be repeated exactly. That made bugs much easier to reproduce, because I could replay the same game.

Step 7: Git and the command line

The whole project lives in a Git repository. I committed after each working step: README first, then the game, then the tests, then this post. That way I could always get back to a version that worked. The basic loop:

bash
git add blackjack.py
git commit -m "Add game loop with betting and player input"
git push

I also used the command line for everything else: making the folder, running the game, running the tests, and moving around with cd and ls.

Conclusion: what I learned
Plan before coding. The must-have list kept me from getting lost in extras.
Keep logic separate from input/output. It made testing easy and the code easier to read.
Edge cases are where the bugs are. Nearly every bug was about aces or the order of the win checks.
Small commits save you. When something broke, I could see exactly what had changed.

Next, I want to add splitting pairs, the obvious missing feature. After that I'd like to save the chip count between sessions and add a hint that tells you the "basic strategy" move for your hand.

If you want to try it, clone the repo and run python3 blackjack.py. Good luck, and remember: the house always wins... eventually.