Apr 2023
Wordle SolverInformation theory and fun

I found myself temporarily addicted to Wordle, so wrote a couple of little solvers to explore the way the game worked

#nonsense #code #misc #internal #text #deep-dive #python #algorithms #information-theory

Back in 2021 I became briefly obsessed by wordle, doing the puzzles religiously every day. After a while I tried to figure out an optimal algorithmic solution.

I came up with a couple of different options. This version is a modified MiniMax, with a lookahead model that manages to reduce the risk of hitting trap words: like when you get _ight and have no option but to blindly guess might, sight, fight etc.

It solves hard-mode wordle for 99.57% of possible wordle words, and averages 3.62 guesses. It fails on boxer, foyer, gaunt, watch, caste, tight, shame, ratty, found, mound.

It's quite a computationally intensive solution, but it's still more computationally efficient than me.

The second version is a modified Markov model - I wanted to try to incorporate statistical features of letter order in English words to adjust a probability estimate for greater accuracy to beat the final 10 words. It performs with significantly lower accuracy than the simple minimax lookahead.

I've since accepted boxer, foyer, and gaunt as my bêtes noires and am satisfied with a 99.57% success rate. Unfortunately a (mostly) reliable machine solution has now taken all the fun out the game and I haven't played wordle since.

If you'd like to have a play around with them yourself, I've put both solvers in full below. Let me know if you think of a hack or modification to capture those last few words! Or if you can think of a completely different architecture that might be fun.

wordle_recursive_minimax.py
import os
import time

# Core logic

def load_words(filename):
    if not os.path.exists(filename):
        return []
    with open(filename, 'r', encoding='utf-8') as file:
        return [line.strip().lower() for line in file if len(line.strip()) == 5]

def get_feedback(guess, target):
    """Wordle colour engine."""
    feedback = ['B'] * 5
    t_chars = list(target)
    g_chars = list(guess)
    # Greens
    for i in range(5):
        if g_chars[i] == t_chars[i]:
            feedback[i] = 'G'
            t_chars[i] = g_chars[i] = None
    # Yellows
    for i in range(5):
        if g_chars[i] is not None and g_chars[i] in t_chars:
            feedback[i] = 'Y'
            t_chars[t_chars.index(g_chars[i])] = None
    return "".join(feedback)

# Recursive lookahead

def get_buckets(guess, pool):
    """Groups a pool of words by the feedback pattern they generate."""
    buckets = {}
    for target in pool:
        pattern = get_feedback(guess, target)
        if pattern not in buckets:
            buckets[pattern] = []
        buckets[pattern].append(target)
    return buckets

def is_guaranteed_solve(guess, pool, turns_left):
    """
    Recursive check: Can this guess solve every word in the pool 
    within the remaining turns?
    """
    if turns_left == 1:
        return len(pool) == 1 and pool[0] == guess
    
    buckets = get_buckets(guess, pool)
    
    # For every possible colour pattern we might get back...
    for pattern, sub_pool in buckets.items():
        if pattern == "GGGGG":
            continue
        
        # We must find at least one word in the next turn that can handle this sub-pool.
        success = False
        # In hard mode, we can only guess from the sub_pool itself
        for next_guess in sub_pool:
            if is_guaranteed_solve(next_guess, sub_pool, turns_left - 1):
                success = True
                break
        
        if not success:
            return False
            
    return True

def get_best_hard_mode_guess(current_pool, attempts_made):
    """
    Decision Engine:
    1. If pool is large, use minimax-ish (split into smallest buckets)
    2. If pool is small (<12), use Recursive Safety to guarantee 100%
    """
    turns_left = 6 - attempts_made
    
    if len(current_pool) <= 2:
        return current_pool[0]

    # Step A: Safety First (for trap words & Harold Lloyd)
    # If pool is small, check if any word guarantees a win
    if len(current_pool) < 12:
        for guess in current_pool:
            if is_guaranteed_solve(guess, current_pool, turns_left):
                return guess

    # Step B: minimax (for general efficiency)
    best_word = ""
    min_worst_case = float('inf')

    for guess in current_pool:
        buckets = get_buckets(guess, current_pool)
        worst_case = max(len(b) for b in buckets.values())
        
        # Tie-breaker: If worst-cases are equal, pick the word that creates the most total buckets (a vaguely entropy based idea)
        if worst_case < min_worst_case:
            min_worst_case = worst_case
            best_word = guess
        elif worst_case == min_worst_case:
            if len(buckets) > len(get_buckets(best_word, current_pool)):
                best_word = guess
                
    return best_word

# Validation tester

def run_final_test():
    targets = load_words('possible_targets.txt')
    if not targets:
        print("Error: possible_targets.txt not found.")
        return

    print("-" * 45)
    print(" recursive validation ")
    print("-" * 45)

    # I initially liked 'raise' as a first guess - near-optimal plus gives the satisfaction of a potential correct first guess, but I've since run the maths on this and think 'salet' reduces the space more, at the cost of a hole in one. I've left blank for now for you to play around with.
    first_guess = ""
    print(f"Opening word: {first_guess.upper()}\n")

    stats = {i: 0 for i in range(1, 8)} 
    failures = []
    start_time = time.time()

    for i, secret_word in enumerate(targets):
        current_pool = targets.copy()
        attempts = 1
        
        while attempts <= 6:
            if attempts == 1:
                guess = first_guess
            else:
                guess = get_best_hard_mode_guess(current_pool, attempts - 1)
            
            pattern = get_feedback(guess, secret_word)
            if pattern == "GGGGG":
                stats[attempts] += 1
                break
            
            # Hard Mode filtering
            current_pool = [w for w in current_pool if get_feedback(guess, w) == pattern]
            attempts += 1
        else:
            stats[7] += 1
            failures.append(secret_word)
            print(f"FAILED: {secret_word.upper()}")

        if (i + 1) % 50 == 0:
            rate = ((i + 1 - stats[7]) / (i + 1)) * 100
            print(f"Checked {i+1}/{len(targets)}... Current Win Rate: {rate:.2f}%")

    # Final report
    total = len(targets)
    duration = time.time() - start_time
    print("\n" + " FINAL PERFORMANCE SUMMARY ".center(45, "="))
    print(f"Win Rate:           {((total - stats[7])/total)*100:.2f}%")
    print(f"Average Guesses:    {sum(k*stats[k] for k in range(1,7))/(total-stats[7]):.3f}")
    print(f"Total Time:         {duration:.1f}s")
    print(f"Failures:           {', '.join(failures) if failures else 'None'}")
    print("=" * 45)

if __name__ == "__main__":
    run_final_test()
	
wordle_hybrid_markov.py
import os
import time

# File loading

def load_words(filename):
    if not os.path.exists(filename):
        return []
    with open(filename, 'r', encoding='utf-8') as file:
        return [line.strip().lower() for line in file if len(line.strip()) == 5]

# Positional splitting (minimax-ish)

def build_split_matrix(possible_targets):
    total_targets = len(possible_targets)
    split_matrix = [{chr(i): 0 for i in range(97, 123)} for _ in range(5)]
    
    for pos in range(5):
        for char_code in range(97, 123):
            char = chr(char_code)
            green, yellow, gray = 0, 0, 0
            for target in possible_targets:
                if target[pos] == char:
                    green += 1
                elif char in target:
                    yellow += 1
                else:
                    gray += 1
            worst_case_bucket = max(green, yellow, gray)
            split_matrix[pos][char] = total_targets - worst_case_bucket
    return split_matrix

# Markov flow (bigrams)

def build_markov_matrix(possible_targets):
    markov_matrix = [{chr(a): {chr(b): 0 for b in range(97, 123)} 
                      for a in range(97, 123)} for _ in range(4)]
    for word in possible_targets:
        for i in range(4):
            char1 = word[i]
            char2 = word[i+1]
            markov_matrix[i][char1][char2] += 1
    return markov_matrix

# Decision engine: Hybrid scoring

def score_guess_hybrid(guess, split_matrix, markov_matrix, markov_weight):
    score = 0
    seen_chars = set()
    
    for pos, char in enumerate(guess):
        score += split_matrix[pos][char]
        if char in seen_chars:
            score -= (split_matrix[pos][char] * 0.5)
        seen_chars.add(char)
        
    markov_bonus = 0
    for i in range(4):
        char1 = guess[i]
        char2 = guess[i+1]
        markov_bonus += markov_matrix[i][char1][char2]
        
    return score + (markov_bonus * markov_weight)

def get_best_hybrid_guess(valid_guesses, possible_targets, markov_weight):
    if len(possible_targets) == 1:
        return possible_targets[0]

    split_matrix = build_split_matrix(possible_targets) 
    markov_matrix = build_markov_matrix(possible_targets)
    
    best_guess = ""
    max_score = -1

    for guess in valid_guesses:
        score = score_guess_hybrid(guess, split_matrix, markov_matrix, markov_weight)
        if score > max_score or (score == max_score and guess in possible_targets):
            max_score = score
            best_guess = guess

    return best_guess

# Game engine rules

def get_feedback(guess, target):
    """Wordle colour engine."""
    feedback = ['B'] * 5
    t_chars = list(target)
    g_chars = list(guess)
    # Greens
    for i in range(5):
        if g_chars[i] == t_chars[i]:
            feedback[i] = 'G'
            t_chars[i] = g_chars[i] = None
    # Yellows
    for i in range(5):
        if g_chars[i] is not None and g_chars[i] in t_chars:
            feedback[i] = 'Y'
            t_chars[t_chars.index(g_chars[i])] = None
    return "".join(feedback)

# Simulation loop (sequential and caching)

def simulate_single_game(target_word, valid_guesses, all_targets, weight, cached_first_guess):
    possible_targets = all_targets.copy()
    attempts = 1
    
    while attempts <= 6:
        # Use the pre-calculated guess on turn 1 to save massive compute time.
        if attempts == 1:
            guess = cached_first_guess
        else:
            guess = get_best_hybrid_guess(valid_guesses, possible_targets, weight)
            
        if guess == target_word:
            return attempts
            
        feedback = get_feedback(guess, target_word)
        possible_targets = [w for w in possible_targets if get_feedback(guess, w) == feedback]
        
        if not possible_targets:
            return 7 
        attempts += 1
        
    return 7 

# Sequential execution manager

def run_simulation(valid_guesses, all_targets):
    weights_to_test = [0.0, 0.5, 1.0, 1.5, 2.0]
    
    # Testing a subset of words (e.g., 50) so the sequential loop doesn't take hours.
    # Change to all_targets if you want to run the full, slow dictionary test.
    test_batch = all_targets
    
    print("opening the tuning lab:")
    print(f"Testing {len(test_batch)} words across {len(weights_to_test)} configurations.")
    
    for weight in weights_to_test:
        start_time = time.time()
        
        print(f"Testing Markov Weight: {weight}")
        print("Pre-calculating optimal first guess...")
        
        # Calculate the opening guess once for this weight
        first_guess = get_best_hybrid_guess(valid_guesses, all_targets, weight)
        print(f"Optimal opening word for weight {weight} is: {first_guess.upper()}")
        print("Running sequential simulation...")
        
        results = []
        for target in test_batch:
            score = simulate_single_game(target, valid_guesses, all_targets, weight, first_guess)
            results.append(score)
        
        # Tally the results.
        losses = sum(1 for score in results if score > 6)
        total_guesses = sum(score if score <= 6 else 7 for score in results)
        
        average_score = total_guesses / len(test_batch)
        elapsed_time = time.time() - start_time
        win_rate = ((len(test_batch) - losses) / len(test_batch)) * 100
        
        print(f"Average Guesses: {average_score:.3f}")
        print(f"Win Rate:        {win_rate:.1f}%")
        print(f"Time Taken:      {elapsed_time:.1f} seconds\n")

if __name__ == "__main__":
    targets = load_words('possible_targets.txt')
    guesses = load_words('valid_guesses.txt')
    
    if targets and guesses:
        guesses = list(set(guesses + targets))
        run_simulation(guesses, targets)
    else:
        print("Missing dictionary files possible_targets.txt and valid_guesses.txt, did you forget to run this in the right folder again?")