#!/usr/bin/env python3 """ pyrdle Game Implementation using ncurses Supports multiple difficulty levels and word categories """ import argparse import curses import random import sys import os from typing import List, Tuple WORDLIST_DIR = "wordlists" class Pyrdle: def __init__(self): self.word_list = [] self.target_word = "" self.guesses = [] self.current_guess = "" self.game_over = False self.won = False self.max_guesses = 6 self.word_length = 5 # Color pairs for the display self.COLOR_DEFAULT = 1 self.COLOR_CORRECT = 2 # Green - letter in correct position self.COLOR_PRESENT = 3 # Yellow - letter in word but wrong position self.COLOR_ABSENT = 4 # Red - letter not in word (confirmed absent) self.COLOR_WHITE = 5 self.COLOR_UNUSED = 6 # Gray - letter not yet used # Keyboard layout for visual keyboard self.keyboard_rows = [ "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM" ] self.letter_status = {} # Track letter status for keyboard coloring def load_words(self, filename: str = f"pyrdle_words.txt") -> bool: """Load word list from file""" filepath = os.path.join(WORDLIST_DIR, filename) try: # Check if file exists if not os.path.exists(filepath): # Try with .txt extension if not provided if not filepath.endswith('.txt'): filepath = filepath + '.txt' if not os.path.exists(filepath): return False with open(filepath, 'r') as f: self.word_list = [word.strip().upper() for word in f.readlines() if len(word.strip()) == self.word_length] if not self.word_list: return False self.target_word = random.choice(self.word_list) return True except FileNotFoundError: return False def is_valid_word(self, word: str) -> bool: """Check if word exists in word list""" return word.upper() in self.word_list def check_guess(self, guess: str) -> List[int]: """ Check guess against target word Returns list of color codes for each letter """ guess = guess.upper() target = self.target_word result = [self.COLOR_ABSENT] * self.word_length # Start with red (not in word) target_chars = list(target) # First pass: mark correct positions for i in range(self.word_length): if guess[i] == target[i]: result[i] = self.COLOR_CORRECT target_chars[i] = None self.letter_status[guess[i]] = self.COLOR_CORRECT # Second pass: mark present but wrong position for i in range(self.word_length): if result[i] == self.COLOR_ABSENT and guess[i] in target_chars: result[i] = self.COLOR_PRESENT target_chars[target_chars.index(guess[i])] = None if guess[i] not in self.letter_status or \ self.letter_status[guess[i]] != self.COLOR_CORRECT: self.letter_status[guess[i]] = self.COLOR_PRESENT elif result[i] == self.COLOR_ABSENT: # Letter is definitely not in the word - mark as red if guess[i] not in self.letter_status: self.letter_status[guess[i]] = self.COLOR_ABSENT return result def make_guess(self, guess: str) -> Tuple[bool, List[int]]: """ Process a guess Returns (success, color_results) """ if len(guess) != self.word_length: return False, [] if not self.is_valid_word(guess): return False, [] colors = self.check_guess(guess) self.guesses.append((guess.upper(), colors)) if guess.upper() == self.target_word: self.game_over = True self.won = True elif len(self.guesses) >= self.max_guesses: self.game_over = True self.won = False return True, colors def draw_title(stdscr, y_offset: int, difficulty: str = ""): """Draw game title""" title = "PYRDLE - The Python Wordle Game" if difficulty: title += f" [{difficulty}]" height, width = stdscr.getmaxyx() x = (width - len(title)) // 2 stdscr.attron(curses.A_BOLD) stdscr.addstr(y_offset, x, title) stdscr.attroff(curses.A_BOLD) def draw_board(stdscr, game: Pyrdle, y_offset: int): """Draw the game board with all guesses""" height, width = stdscr.getmaxyx() board_x = (width - (game.word_length * 4 - 1)) // 2 for row in range(game.max_guesses): y = y_offset + row * 2 if row < len(game.guesses): # Draw a completed guess guess, colors = game.guesses[row] for col, (letter, color) in enumerate(zip(guess, colors)): x = board_x + col * 4 stdscr.attron(curses.color_pair(color)) stdscr.addstr(y, x, f"[{letter}]") stdscr.attroff(curses.color_pair(color)) elif row == len(game.guesses) and not game.game_over: # Draw current guess for col in range(game.word_length): x = board_x + col * 4 if col < len(game.current_guess): letter = game.current_guess[col] else: letter = " " stdscr.attron(curses.color_pair(game.COLOR_WHITE)) stdscr.addstr(y, x, f"[{letter}]") stdscr.attroff(curses.color_pair(game.COLOR_WHITE)) else: # Draw empty slots for col in range(game.word_length): x = board_x + col * 4 stdscr.addstr(y, x, "[ ]") def draw_keyboard(stdscr, game: Pyrdle, y_offset: int): """Draw visual keyboard showing letter status""" height, width = stdscr.getmaxyx() for row_idx, row in enumerate(game.keyboard_rows): # Center each keyboard row x = (width - (len(row) * 2 - 1)) // 2 y = y_offset + row_idx # Add indentation for second and third rows if row_idx == 1: x += 1 elif row_idx == 2: x += 3 for letter in row: # Get color based on letter status if letter in game.letter_status: color = game.letter_status[letter] else: # Letter not yet used - show in gray/dim color = game.COLOR_UNUSED # Apply dim attribute for unused letters if color == game.COLOR_UNUSED: stdscr.attron(curses.A_DIM) stdscr.addstr(y, x, letter) stdscr.attroff(curses.A_DIM) else: stdscr.attron(curses.color_pair(color)) stdscr.addstr(y, x, letter) stdscr.attroff(curses.color_pair(color)) x += 2 def draw_message(stdscr, message: str, y_offset: int, color_pair: int = 1): """Draw centered message""" height, width = stdscr.getmaxyx() x = (width - len(message)) // 2 stdscr.attron(curses.color_pair(color_pair)) stdscr.addstr(y_offset, x, message) stdscr.attroff(curses.color_pair(color_pair)) def draw_instructions(stdscr, y_offset: int): """Draw game instructions""" instructions = [ "Guess the 5-letter word in 6 tries!", "", "Colors: GREEN=Correct, YELLOW=Wrong position, RED=Not in word", "", "Type letters: A-Z", "Enter: Submit guess", "Backspace: Delete letter", "Ctrl+C: Quit game" ] height, width = stdscr.getmaxyx() for i, line in enumerate(instructions): if y_offset + i < height - 1: x = (width - len(line)) // 2 stdscr.addstr(y_offset + i, x, line) def parse_arguments(): """Parse command line arguments for difficulty/category selection""" parser = argparse.ArgumentParser( description='pyrdle Game - Guess the 5-letter word!', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Difficulty Levels: --easy Common everyday words (default) --medium Standard vocabulary including less common words --hard Challenging words including archaic and technical terms Word Categories: --common Common English words (default) --literary Literary and archaic terms --techy Technical and scientific terms --cultural Words from various cultures and languages --full Complete dictionary with all words Examples: python3 pyrdle.py --easy python3 pyrdle.py --techy python3 pyrdle.py --hard --literary """ ) # Difficulty levels (mutually exclusive) difficulty = parser.add_mutually_exclusive_group() difficulty.add_argument('--easy', action='store_true', help='Easy mode: common words only') difficulty.add_argument('--medium', action='store_true', help='Medium mode: standard vocabulary') difficulty.add_argument('--hard', action='store_true', help='Hard mode: includes obscure words') # Word categories (mutually exclusive) category = parser.add_mutually_exclusive_group() category.add_argument('--common', action='store_true', help='Common everyday words') category.add_argument('--literary', action='store_true', help='Literary and archaic terms') category.add_argument('--techy', action='store_true', help='Technical and scientific terms') category.add_argument('--cultural', action='store_true', help='Cultural and international terms') category.add_argument('--full', action='store_true', help='Full dictionary (all words)') # Custom word list parser.add_argument('--wordlist', type=str, metavar='FILE', help='Use custom word list file') args = parser.parse_args() # Determine which word list to use if args.wordlist: return args.wordlist, "CUSTOM" # Map arguments to word list files if args.techy: return "pyrdle_words_techy.txt", "TECHNICAL" elif args.literary: return "pyrdle_words_literary.txt", "LITERARY" elif args.cultural: return "pyrdle_words_cultural.txt", "CULTURAL" elif args.full: return "pyrdle_words_full.txt", "FULL" elif args.hard: return "pyrdle_words_hard.txt", "HARD" elif args.medium: return "pyrdle_words_medium.txt", "MEDIUM" else: # Default to easy/common return "pyrdle_words_easy.txt", "EASY" def main(stdscr): """Main game loop""" # Parse command line arguments word_file, difficulty_name = parse_arguments() # Initialize curses curses.curs_set(0) # Hide cursor stdscr.clear() # Initialize colors curses.start_color() curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) # Default curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN) # Correct position curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_YELLOW) # Wrong position curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_RED) # Not in word (confirmed absent) curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_BLACK) # Current input curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK) # Unused letters (gray/dim) # Initialize game game = Pyrdle() if not game.load_words(word_file): # Try fallback to default word list if word_file != "pyrdle_words.txt" and game.load_words("pyrdle_words.txt"): difficulty_name = "DEFAULT" else: stdscr.addstr(0, 0, f"Error: Could not load word list from {word_file}") stdscr.addstr(1, 0, "Press any key to exit...") stdscr.getch() return message = "" message_color = 1 while True: stdscr.clear() height, width = stdscr.getmaxyx() # Calculate vertical positions y_pos = 1 # Draw components draw_title(stdscr, y_pos, difficulty_name) y_pos += 2 draw_board(stdscr, game, y_pos) y_pos += game.max_guesses * 2 + 1 draw_keyboard(stdscr, game, y_pos) y_pos += 4 # Draw message if exists if message: draw_message(stdscr, message, y_pos, message_color) y_pos += 2 # Draw instructions at bottom if y_pos + 6 < height: draw_instructions(stdscr, y_pos) # Show game over message if game.game_over: y_pos = height - 3 if height > 20 else y_pos if game.won: win_message = f"Congratulations! You won in {len(game.guesses)} guesses!" draw_message(stdscr, win_message, y_pos, 2) else: lose_message = f"Game Over! The word was: {game.target_word}" draw_message(stdscr, lose_message, y_pos, 4) draw_message(stdscr, "Press 'N' for new game or 'Q' to quit", y_pos + 1, 1) stdscr.refresh() key = stdscr.getch() if key in [ord('n'), ord('N')]: # Start new game game = Pyrdle() game.load_words(word_file) message = "" continue elif key in [ord('q'), ord('Q'), 27]: # 27 is ESC break continue stdscr.refresh() # Get input key = stdscr.getch() # Handle input if key == 27: # ESC key break elif key == ord('\n'): # Enter key if len(game.current_guess) == game.word_length: success, _ = game.make_guess(game.current_guess) if success: game.current_guess = "" message = "" else: message = "Not in word list!" message_color = 4 else: message = f"Word must be {game.word_length} letters!" message_color = 4 elif key == curses.KEY_BACKSPACE or key == 127: # Backspace if game.current_guess: game.current_guess = game.current_guess[:-1] message = "" elif ord('a') <= key <= ord('z'): # Lowercase letter if len(game.current_guess) < game.word_length: game.current_guess += chr(key).upper() message = "" elif ord('A') <= key <= ord('Z'): # Uppercase letter if len(game.current_guess) < game.word_length: game.current_guess += chr(key) message = "" if __name__ == "__main__": # Handle help before initializing curses if "--help" in sys.argv or "-h" in sys.argv: # Parse arguments just to show help word_file, difficulty = parse_arguments() sys.exit(0) try: curses.wrapper(main) except KeyboardInterrupt: sys.exit(0)