Learning Goals
3 minBy the end of this lesson you can:
- Use ANSI escape codes to print coloured backgrounds in the terminal.
- Re-render a full six-row Wordle board after every guess.
- Print a stats line — guesses used, time taken, distinct letters tried.
- Save every game's result to a file and load lifetime stats on the next run.
Warm-Up · ANSI in Two Minutes
5 minTerminals understand a tiny language of escape codes. The sequence \033[Xm changes the colour; \033[0m resets it. Run this:
print("\033[42m G \033[0m \033[43m Y \033[0m .") # → a green box, a yellow box, and a dot
If you see two coloured blocks in the output, your terminal supports ANSI — which most modern ones do (VS Code, macOS Terminal, Windows Terminal, online-python.com). If you just see the raw text \033[42m, your environment doesn't — fall back to the brackets/parens from Part 2.
Polish is what turns "a working game" into "a game you want to play again". Colours, a real board, stats, and persistence — those four upgrades are universal.
New Concept · Four Polish Moves
14 min1 · A constants block for colours
Put all the magic escape codes at the top of the file. Future-you will thank you.
GREEN = "\033[97;42m" # white text on green background YELLOW = "\033[30;43m" # black text on yellow background GREY = "\033[97;100m" # white text on grey background RESET = "\033[0m"
Each code is a foreground/background combo. 97;42 = bright white on green; 30;43 = black on yellow.
2 · A colour-aware show()
def show_row(guess, result): parts = [] for letter, mark in zip(guess, result): if mark == "G": parts.append(f"{GREEN} {letter.upper()} {RESET}") elif mark == "Y": parts.append(f"{YELLOW} {letter.upper()} {RESET}") else: parts.append(f"{GREY} {letter.upper()} {RESET}") print("".join(parts))
Each tile is three characters wide — a space, the letter, another space — surrounded by the colour codes.
3 · The whole board, every turn
Six rows. As guesses come in, fill them; leave the remaining rows blank.
def render_board(history): for guess, result in history: show_row(guess, result) for _ in range(6 - len(history)): print(f"{GREY} . . . . . {RESET}")
Call render_board(history) at the start of every turn — even before the first guess — so the player always sees the empty grid.
4 · A stats line
Once the game ends, print one line summarising how it went.
import time def stats_line(secret, history, started): won = (len(history) > 0 and history[-1][1] == ["G"]*5) n = len(history) seconds = int(time.time() - started) letters = set("".join(g for g, _ in history)) return ( f"{'WIN' if won else 'LOSS'} " f"secret: {secret.upper()} " f"guesses: {n}/6 " f"time: {seconds}s " f"distinct letters tried: {len(letters)}" )
We use time.time() from the time module to measure how long the game took. We'll meet datetime properly in PY-L2-32 — for now, time.time() is just "seconds since 1970".
Worked Example · The Shipped Game
12 minSave the complete file as wordle_final.py. Everything we've built across Parts 1, 2 and 3 in one place.
Code
# wordle_final.py — colour + board + stats + lifetime save import random import time GREEN = "\033[97;42m" YELLOW = "\033[30;43m" GREY = "\033[97;100m" RESET = "\033[0m" # --- Part 1 functions --- def load_word_bank(path): with open(path, encoding="utf-8") as f: return [line.strip().lower() for line in f if line.strip()] # --- Part 2 functions --- def feedback(secret, guess): result = ["."] * 5 remaining = list(secret) for i in range(5): if guess[i] == secret[i]: result[i] = "G" remaining[i] = None for i in range(5): if result[i] == "G": continue if guess[i] in remaining: result[i] = "Y" remaining[remaining.index(guess[i])] = None return result # --- Part 3 polish --- def show_row(guess, result): parts = [] for letter, mark in zip(guess, result): if mark == "G": parts.append(f"{GREEN} {letter.upper()} {RESET}") elif mark == "Y": parts.append(f"{YELLOW} {letter.upper()} {RESET}") else: parts.append(f"{GREY} {letter.upper()} {RESET}") print("".join(parts)) def render_board(history): for guess, result in history: show_row(guess, result) for _ in range(6 - len(history)): print(f"{GREY} . . . . . {RESET}") def save_result(secret, history, won): n = len(history) with open("wordle_log.txt", "a", encoding="utf-8") as f: f.write(f"{secret},{n},{'W' if won else 'L'}\n") def lifetime_stats(): try: with open("wordle_log.txt", encoding="utf-8") as f: rows = [line.strip().split(",") for line in f if line.strip()] except FileNotFoundError: return "(no history yet)" games = len(rows) wins = sum(1 for r in rows if r[2] == "W") avg_g = sum(int(r[1]) for r in rows if r[2] == "W") / max(1, wins) return f"Lifetime: {games} games · {wins} wins · avg guesses on a win: {avg_g:.2f}" # --- play --- def play_one(bank): secret = random.choice(bank) history = [] started = time.time() while len(history) < 6: render_board(history) guess = input("\nGuess: ").strip().lower() if len(guess) != 5 or not guess.isalpha(): print("Five letters, alphabetical only.") continue if guess not in bank: print("Not in word bank.") continue result = feedback(secret, guess) history.append((guess, result)) if result == ["G"] * 5: render_board(history) print(f"\n🎉 Solved in {len(history)}!") save_result(secret, history, True) return render_board(history) print(f"\n🪦 Out of guesses. Word was: {secret.upper()}") save_result(secret, history, False) bank = load_word_bank("wordbank.txt") print(lifetime_stats()) while True: play_one(bank) if input("\nPlay again? (y/n) ").lower() != "y": break print(lifetime_stats())
Sample run (secret = "peach")
Lifetime: 4 games · 3 wins · avg guesses on a win: 4.33 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Guess: place P . A C e . . . . . . . . . . . . . . . . . . . . . . . . . Guess: peach P E A C H 🎉 Solved in 2!
(In a real terminal, the green and yellow tiles render as coloured blocks — the above shows the ASCII fallback for clarity.)
Read the diff
Look at the file as a stack: Part 1 functions, Part 2 function, Part 3 polish, then a tiny play_one. The work is divided cleanly; you can re-use any layer in another game. save_result is the same three-column CSV-line shape from PY-L2-22; lifetime_stats reads it back. The board re-renders every turn, which on a real terminal feels like a proper game UI.
Try It Yourself
13 minSwap the green to magenta (45) and the yellow to cyan (46). Run a game. Notice how nothing else needs changing — that's the value of the constants block.
If the player hasn't won by turn 4, print the first letter of the secret as a free hint — before they type their next guess.
Hint
# in play_one, before asking for the guess: if len(history) == 3: print(f"💡 Hint: word starts with '{secret[0]}'")
One if, one print. Nice quality-of-life touch that doesn't change the game logic.
In lifetime_stats, also print the best (fewest guesses on a win) and worst (most guesses on a loss) games so far.
Hint
wins = [int(r[1]) for r in rows if r[2] == "W"] losses = [int(r[1]) for r in rows if r[2] == "L"] best = min(wins) if wins else None worst = max(losses) if losses else None return ( f"Lifetime: {games} games · {wins_count} wins · " f"best: {best} · worst (loss): {worst}" )
Two list comprehensions, two min/max calls, one f-string. The lifetime log is now a real record.
Mini-Challenge · Daily Wordle Mode
8 minBuild wordle_daily.py. Pick the secret by today's date, not at random. Everybody who plays on the same day gets the same word — like real Wordle.
Your file must:
- Use
date.today().toordinal()as the random seed. That number changes daily and is stable across the world. - After seeding, pick the secret with
random.choice. Same date = same word for every player. - Track whether the user has already played today (write the last-played date to a file, refuse a second play on the same day).
Stretch goal. Print a small ASCII-art share grid at the end — the same shape Wordle posts to Twitter. Use 🟩 🟨 ⬜ emoji.
Show one possible solution
# wordle_daily.py — same word for everyone, once a day from datetime import date import random # (paste Parts 1-3 functions here, or import from wordle_final) LAST_PLAYED = "last_played.txt" def already_played_today(): try: with open(LAST_PLAYED, encoding="utf-8") as f: return f.read().strip() == date.today().isoformat() except FileNotFoundError: return False def mark_played_today(): with open(LAST_PLAYED, "w", encoding="utf-8") as f: f.write(date.today().isoformat()) if already_played_today(): print("You've already played today's puzzle. Try again tomorrow!") raise SystemExit random.seed(date.today().toordinal()) bank = load_word_bank("wordbank.txt") secret = random.choice(bank) # ... regular play_one logic with this fixed secret ... mark_played_today() # Stretch — share grid EMOJI = {"G": "🟩", "Y": "🟨", ".": "⬜"} print("\nWordle-Lite", date.today().isoformat()) print(f"{len(history)}/6\n") for _, result in history: print("".join(EMOJI[m] for m in result))
Non-negotiables: deterministic seed from the date, a "last played" file check, and refusal to play twice in a day. The emoji share grid is the polish that makes the game shareable.
Recap
3 minWordle-Lite is shipped. ANSI escape codes paint real colour tiles in the terminal. A six-row board re-renders each turn, fillable from a history list. time.time() lets us measure how long a game takes. A simple three-column log file gives us lifetime stats across every game ever played. Four small touches turn a working algorithm into a real game.
Vocabulary Card
- ANSI escape codes
- Tiny text sequences (
\033[...m) that change terminal colours.\033[0mresets. - render_board
- A function that prints the entire game state from scratch each turn — never appends incrementally.
- lifetime stats
- A log file the game appends one line to per session, plus a loader that summarises it.
- daily seed
- Using
date.today().toordinal()as the random seed — same word for every player on the same date.
Homework
4 minAdd an in-game keyboard to your wordle_final.py. After each guess, print the alphabet with letters coloured by their best-so-far status:
- Green if any guess has placed the letter correctly.
- Yellow if any guess has had the letter in the wrong place but never correctly.
- Grey if the player has tried the letter and it's not in the word.
- Uncoloured (plain) if not tried.
Print the keyboard in three rows like a real QWERTY keyboard.
Sample · keyboard renderer
KEYBOARD_ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm"] def render_keyboard(history): status = {} # letter -> "G" | "Y" | "." PRIORITY = {"G": 3, "Y": 2, ".": 1} for guess, result in history: for letter, mark in zip(guess, result): if PRIORITY[mark] > PRIORITY.get(status.get(letter, "."), 0): status[letter] = mark for row in KEYBOARD_ROWS: parts = [] for letter in row: mark = status.get(letter) if mark == "G": parts.append(f"{GREEN} {letter.upper()} {RESET}") elif mark == "Y": parts.append(f"{YELLOW} {letter.upper()} {RESET}") elif mark == ".": parts.append(f"{GREY} {letter.upper()} {RESET}") else: parts.append(f" {letter.upper()} ") print("".join(parts)) # Call render_keyboard(history) right after render_board(history) in play_one.
Non-negotiables: a status dict per letter, a priority order so green can't be downgraded to yellow, and three keyboard rows printed below the board. The priority dict is the subtle bit — without it, "already green" can get overwritten to yellow on a later guess.