Learning Goals
3 minBy the end of this lesson you can:
- Write a
feedback(secret, guess)function that returns a list of"G"/"Y"/"."codes. - Handle the duplicate-letter trap correctly — guessing
llamaagainstballsshould only flag one 'l'. - Pretty-print the feedback under each guess using brackets and dots.
- Wire it into a six-guess game loop using your Part-1 word bank loader.
Warm-Up · The Rules, Carefully
5 minLet's nail down the rules with concrete examples. Secret = peach.
Guess: place p → G (peach starts with p too) l → . (not in peach) a → G (peach[2] is 'a' too) c → G (peach[3] is 'c' too) e → Y (e IS in peach — at index 1, not 4)
Three greens, one yellow, one grey. So far so good.
Now the trap. Secret = balls. Guess = llama.
Naive logic (wrong!)
l → Y (l is in balls)
l → Y (l is in balls — count it again!)
a → Y (a is in balls)
m → .
a → Y (a is in balls — count it again!)
Correct logic
l → Y (one yellow l)
l → . (second l — balls only has TWO l's, but we already marked one)
Actually balls has 2 l's, so this is also Y? No — the
two l's in the guess match the two l's in the secret, but
neither is in the right *position*, so it depends on order.
The rule: every secret letter can only be "consumed" once.The fix is a two-pass algorithm: first mark all the greens (and consume those secret letters), then go back and mark yellows from the leftovers. Let's write it.
Wordle's feedback isn't letter-by-letter — it's two passes. Greens first, yellows from the remainder. Anything else gets duplicate letters wrong.
New Concept · The Two-Pass Feedback Algorithm
14 minThe result codes
G green — right letter, right place Y yellow — right letter, wrong place . grey — not in the word (or already accounted for)
The function returns a list of five codes — one per guess letter — so the calling code can colour-print or score.
Pass 1 — Greens
Walk both strings position by position. If guess[i] == secret[i], mark G. Remember to cross off that secret letter so a future yellow can't match it again. We'll keep a list of "leftover" letters from the secret to track that.
def feedback(secret, guess): result = ["."] * 5 # start as all grey remaining = list(secret) # leftover letters from the secret # Pass 1 — mark greens for i in range(5): if guess[i] == secret[i]: result[i] = "G" remaining[i] = None # crossed off return result, remaining # we'll keep going below
The remaining[i] = None step is the trick. We don't just remove from the list (positions matter); we replace with None so the index still aligns.
Pass 2 — Yellows
Walk the guess again. For each non-green letter, check if it appears anywhere in the still-uncrossed leftovers. If yes, mark Y and cross that letter off.
# Pass 2 — mark yellows for i in range(5): if result[i] == "G": continue # already done if guess[i] in remaining: result[i] = "Y" remaining[remaining.index(guess[i])] = None # cross off ONE copy return result
remaining.index(x) returns the index of the first match. We replace just that one with None — so each secret letter can only be consumed once.
The full function
def feedback(secret, guess): if len(guess) != 5: return None # signal "invalid 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
Quick sanity tests
print(feedback("peach", "place")) # → ['G', '.', 'G', 'G', 'Y'] print(feedback("balls", "llama")) # → ['Y', 'Y', '.', '.', '.'] # ^ ^ # both 'l's score against balls' two l's. The 'a' is also # eaten by the green-pass-not-applicable / yellow-pass rule. print(feedback("peach", "peach")) # → ['G', 'G', 'G', 'G', 'G']
If you tried to mark green and yellow in one walk, you'd need to look ahead — "is this letter a green later on?" — which gets messy. Two passes is clearer and handles duplicate letters correctly without any extra logic.
Worked Example · One Full Round of Wordle-Lite
12 minSave as wordle_part2.py. Pair the new feedback function with Part 1's loader to play one round.
Code
# wordle_part2.py — load + feedback + guess loop import random def load_word_bank(path): with open(path, encoding="utf-8") as f: return [line.strip().lower() for line in f if line.strip()] def validate(bank): return [w for w in bank if len(w) != 5 or not w.isalpha()] def feedback(secret, guess): if len(guess) != 5: return None 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 def show(guess, result): # Print each letter inside [ ] for greens, ( ) for yellows, plain dot for misses line = [] for letter, mark in zip(guess, result): if mark == "G": line.append(f"[{letter.upper()}]") elif mark == "Y": line.append(f"({letter})") else: line.append(" . ") print(" ".join(line)) # --- main game --- bank = load_word_bank("wordbank.txt") if validate(bank): print("Word bank has invalid entries — fix wordbank.txt first.") raise SystemExit secret = random.choice(bank) print("Welcome to Wordle-Lite. Six guesses. Good luck!\n") for turn in range(1, 7): guess = input(f"Guess #{turn}: ").strip().lower() if len(guess) != 5 or not guess.isalpha(): print(" ! Must be 5 letters, alphabetical.") continue if guess not in bank: print(" ! Not in word bank.") continue result = feedback(secret, guess) show(guess, result) if result == ["G"] * 5: print(f"\n🎉 Solved in {turn} guess(es)!") break else: print(f"\n🪦 Out of guesses. Secret was: {secret.upper()}")
Sample play (secret was "peach")
Welcome to Wordle-Lite. Six guesses. Good luck! Guess #1: place [P] . [A] [C] (e) Guess #2: peach [P] [E] [A] [C] [H] 🎉 Solved in 2 guess(es)!
Read the diff
Three little design choices to spot. (1) show uses brackets and parens to colour without real terminal colours — works everywhere. (2) Invalid guesses (wrong length or not in the bank) don't consume a turn — we continue instead. (3) The for/else on the main loop fires only if the loop didn't break — the cleanest way to handle "out of guesses". We met for/else in PY-L1-12.
If your editor's terminal supports ANSI escape codes (most do), you can swap the brackets for real green/yellow text — print("\\033[42m G \\033[0m") for a green block. We'll polish that in Part 3 (PY-L2-25).
Try It Yourself
13 minRun the worked example five times. On each game, type one guess equal to the secret's "cheat" (find it by adding print("DEBUG secret:", secret) just for testing). Confirm all five letters are G.
Hint
Use the debug line only while testing. Remove it before play. Catching that you forgot to remove a debug line is one of the most common bug fixes you'll ever do as a developer.
Outside the game loop, write three quick prints to confirm the duplicate-letter rule. Each line should produce the comment shown.
print(feedback("balls", "llama")) # ['Y', 'Y', '.', '.', '.'] print(feedback("balls", "lulls")) # ['Y', '.', '.', 'G', 'G'] print(feedback("apple", "alpha")) # ['G', '.', '.', 'Y', '.']
Hint
If your feedback returns the wrong code anywhere, your duplicate-letter logic is broken. Re-read the two-pass code carefully — the remaining[i] = None and the remaining.index(...) together are what makes it correct.
Track every letter the player has guessed at least once. After each guess, print three sets: used green/yellow, used grey, not yet tried. Helps the player not waste guesses.
Hint
hits = set() misses = set() # Inside the guess loop, after each guess: for letter, mark in zip(guess, result): if mark in ("G", "Y"): hits.add(letter) else: misses.add(letter) # Subtract for the "not yet tried" set all_letters = set("abcdefghijklmnopqrstuvwxyz") untried = all_letters - hits - misses print("Hits :", sorted(hits)) print("Misses:", sorted(misses)) print("Untried:", " ".join(sorted(untried)))
The three sets sum to all 26 letters — set algebra from PY-L2-09 in action. Misses can technically overlap with hits if the same letter appeared both as a green/yellow and as a grey (the duplicate-letter case). For a stricter board, take care to remove hit-letters from the miss set: misses = misses - hits.
Mini-Challenge · Wordle-Lite With History
8 minBuild wordle_history.py. Same game as the worked example — but every guess and its feedback are stored in a history list, and printed cleanly at the end of each round.
Your file must:
- Keep
historyas a list of(guess, result)tuples. - After every accepted guess, append to
historyand re-print the entire history, like a real Wordle board. - At the end (win or lose), print the final board one more time, then write the same lines to a file
last_game.txtusing PY-L2-21's file-writing pattern.
Stretch goal. Use ANSI colour codes for greens and yellows in the printed board (but keep the file plain).
Show one possible solution (key snippets)
history = [] def print_board(history): for guess, result in history: show(guess, result) # inside the loop: result = feedback(secret, guess) history.append((guess, result)) print() print_board(history) # at the end: with open("last_game.txt", "w", encoding="utf-8") as f: for guess, result in history: for letter, mark in zip(guess, result): ch = letter.upper() if mark == "G" else letter if mark == "Y" else "." f.write(ch + " ") f.write("\n")
Non-negotiables: a history list, a re-print of the board after every guess, and the same board saved to a file at end-of-game. ANSI is optional — the brackets/parens version works on every terminal.
Recap
3 minWordle's feedback is a two-pass algorithm — greens first, then yellows from what's left. The duplicate-letter trap is handled by crossing off secret letters as we use them (replace with None in a leftover list, then check in and .index()). Wrap that single function in your Part-1 loader plus a six-turn loop and you have a complete word game. Tomorrow we polish the board with real colours, stats and a play-again loop.
Vocabulary Card
- two-pass algorithm
- An algorithm that walks the data twice — once for the easy/strong matches, again for the harder/weaker ones.
- green / yellow / grey
- Wordle's three feedback codes. We use
G,Y,.as ASCII stand-ins. - cross-off list
- A copy of the secret with letters set to
Noneas they get consumed by a match. Prevents double-counting. - for/else
- Python loop trick. The
elseblock runs only if the loop wasn't broken out of — perfect for "ran out of attempts".
Homework
4 minWrite feedback_tests.py — a tiny test suite for your feedback function. Each test is a tuple: (secret, guess, expected). Loop the list, call the function, compare with ==, print OK or FAIL.
Your file must include at least these tests, plus three more you invent:
tests = [ # secret guess expected ("peach", "peach", ["G","G","G","G","G"]), ("peach", "place", ["G",".","G","G","Y"]), ("balls", "llama", ["Y","Y",".",".","."]), ("balls", "lulls", ["Y",".",".","G","G"]), ("apple", "alpha", ["G",".",".","Y","."]), # add your own: # ... ]
The output should look like:
OK peach + peach OK peach + place OK balls + llama OK balls + lulls OK apple + alpha ... 5/5 passing.
Stretch. If a test fails, print the actual vs expected result inline so you can see where it diverged.
Sample · feedback_tests.py
# feedback_tests.py — confirm feedback() works on tricky cases 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 tests = [ ("peach", "peach", ["G","G","G","G","G"]), ("peach", "place", ["G",".","G","G","Y"]), ("balls", "llama", ["Y","Y",".",".","."]), ("balls", "lulls", ["Y",".",".","G","G"]), ("apple", "alpha", ["G",".",".","Y","."]), ("tiger", "siege", [".","G","Y","G","."]), ("water", "wager", ["G","G",".","G","G"]), ("zesty", "yelps", [".","Y",".",".","Y"]), ] passing = 0 for secret, guess, expected in tests: actual = feedback(secret, guess) if actual == expected: print(f"OK {secret} + {guess}") passing += 1 else: print(f"FAIL {secret} + {guess}") print(f" expected {expected}") print(f" got {actual}") print(f"\n{passing}/{len(tests)} passing.")
Non-negotiables: at least five test cases, an OK/FAIL print per test, and a final count line. Building tests for a function is what separates "works once" from "works always". We'll meet the proper unittest module in Level 4 — this hand-rolled style is your first taste.