Skip to main content

Puzzle Game Logic - Python Implementation

Complete Puzzle Game Implementation in Python

This is the complete Python/Pygame implementation featuring multiple puzzle types: Match-3 with cascading combos, sliding tile puzzles, physics-based challenges, pattern memory games, and logic gate puzzles.

Full Puzzle Game Code

import pygame
import random
import math
from typing import List, Tuple, Optional, Dict
from dataclasses import dataclass
from enum import Enum
import copy

class PuzzleType(Enum):
    MATCH3 = "match3"
    SLIDING = "sliding"
    PHYSICS = "physics"
    LOGIC = "logic"
    PATTERN = "pattern"

# Match-3 Game Implementation
class Match3Game:
    """Match-3 puzzle game"""
    
    def __init__(self, grid_size: int = 8, gem_types: int = 6):
        self.grid_size = grid_size
        self.gem_types = gem_types
        self.grid = []
        self.selected = None
        self.score = 0
        self.combo = 1
        self.moves = 0
        
        # Gem colors
        self.gem_colors = [
            (255, 0, 0),    # Red
            (0, 255, 0),    # Green
            (0, 0, 255),    # Blue
            (255, 255, 0),  # Yellow
            (255, 0, 255),  # Purple
            (0, 255, 255)   # Cyan
        ]
        
        self.initialize_grid()
    
    def initialize_grid(self):
        """Create initial game grid"""
        self.grid = []
        
        for y in range(self.grid_size):
            row = []
            for x in range(self.grid_size):
                # Ensure no initial matches
                valid_gem = False
                while not valid_gem:
                    gem_type = random.randint(0, self.gem_types - 1)
                    
                    # Check horizontal match
                    if x >= 2:
                        if row[x-1] == gem_type and row[x-2] == gem_type:
                            continue
                    
                    # Check vertical match
                    if y >= 2:
                        if (self.grid[y-1][x] == gem_type and 
                            self.grid[y-2][x] == gem_type):
                            continue
                    
                    valid_gem = True
                
                row.append(gem_type)
            self.grid.append(row)
    
    def swap_gems(self, x1: int, y1: int, x2: int, y2: int) -> bool:
        """Swap two adjacent gems"""
        # Check if adjacent
        if abs(x1 - x2) + abs(y1 - y2) != 1:
            return False
        
        # Perform swap
        self.grid[y1][x1], self.grid[y2][x2] = self.grid[y2][x2], self.grid[y1][x1]
        
        # Check for matches
        matches = self.find_matches()
        
        if matches:
            self.process_matches(matches)
            self.moves += 1
            return True
        else:
            # Swap back if no matches
            self.grid[y1][x1], self.grid[y2][x2] = self.grid[y2][x2], self.grid[y1][x1]
            return False
    
    def find_matches(self) -> List[Tuple[int, int]]:
        """Find all matching gems"""
        matches = set()
        
        # Check horizontal matches
        for y in range(self.grid_size):
            for x in range(self.grid_size - 2):
                if (self.grid[y][x] == self.grid[y][x+1] == self.grid[y][x+2] 
                    and self.grid[y][x] != -1):
                    matches.add((x, y))
                    matches.add((x+1, y))
                    matches.add((x+2, y))
                    
                    # Check for longer matches
                    i = x + 3
                    while i < self.grid_size and self.grid[y][i] == self.grid[y][x]:
                        matches.add((i, y))
                        i += 1
        
        # Check vertical matches
        for x in range(self.grid_size):
            for y in range(self.grid_size - 2):
                if (self.grid[y][x] == self.grid[y+1][x] == self.grid[y+2][x] 
                    and self.grid[y][x] != -1):
                    matches.add((x, y))
                    matches.add((x, y+1))
                    matches.add((x, y+2))
                    
                    # Check for longer matches
                    i = y + 3
                    while i < self.grid_size and self.grid[i][x] == self.grid[y][x]:
                        matches.add((x, i))
                        i += 1
        
        return list(matches)
    
    def process_matches(self, matches: List[Tuple[int, int]]):
        """Process matched gems"""
        # Award points
        base_score = len(matches) * 10
        self.score += base_score * self.combo
        
        # Remove matched gems
        for x, y in matches:
            self.grid[y][x] = -1
        
        # Drop gems
        self.drop_gems()
        
        # Fill empty spaces
        self.fill_empty()
        
        # Check for cascading matches
        new_matches = self.find_matches()
        if new_matches:
            self.combo += 1
            self.process_matches(new_matches)
        else:
            self.combo = 1
    
    def drop_gems(self):
        """Drop gems to fill empty spaces"""
        for x in range(self.grid_size):
            # Compact column
            write_pos = self.grid_size - 1
            
            for y in range(self.grid_size - 1, -1, -1):
                if self.grid[y][x] != -1:
                    if y != write_pos:
                        self.grid[write_pos][x] = self.grid[y][x]
                        self.grid[y][x] = -1
                    write_pos -= 1
    
    def fill_empty(self):
        """Fill empty spaces with new gems"""
        for y in range(self.grid_size):
            for x in range(self.grid_size):
                if self.grid[y][x] == -1:
                    self.grid[y][x] = random.randint(0, self.gem_types - 1)
    
    def has_possible_moves(self) -> bool:
        """Check if there are any possible moves"""
        for y in range(self.grid_size):
            for x in range(self.grid_size):
                # Try swapping with right neighbor
                if x < self.grid_size - 1:
                    self.grid[y][x], self.grid[y][x+1] = self.grid[y][x+1], self.grid[y][x]
                    if self.find_matches():
                        self.grid[y][x], self.grid[y][x+1] = self.grid[y][x+1], self.grid[y][x]
                        return True
                    self.grid[y][x], self.grid[y][x+1] = self.grid[y][x+1], self.grid[y][x]
                
                # Try swapping with bottom neighbor
                if y < self.grid_size - 1:
                    self.grid[y][x], self.grid[y+1][x] = self.grid[y+1][x], self.grid[y][x]
                    if self.find_matches():
                        self.grid[y][x], self.grid[y+1][x] = self.grid[y+1][x], self.grid[y][x]
                        return True
                    self.grid[y][x], self.grid[y+1][x] = self.grid[y+1][x], self.grid[y][x]
        
        return False
    
    def shuffle_board(self):
        """Shuffle the board if no moves available"""
        gems = []
        for y in range(self.grid_size):
            for x in range(self.grid_size):
                gems.append(self.grid[y][x])
        
        random.shuffle(gems)
        
        i = 0
        for y in range(self.grid_size):
            for x in range(self.grid_size):
                self.grid[y][x] = gems[i]
                i += 1

# Sliding Puzzle Implementation
class SlidingPuzzle:
    """Sliding tile puzzle game"""
    
    def __init__(self, size: int = 4):
        self.size = size
        self.grid = []
        self.empty_pos = (size - 1, size - 1)
        self.moves = 0
        self.solved = False
        
        self.initialize_puzzle()
        self.shuffle()
    
    def initialize_puzzle(self):
        """Create solved puzzle state"""
        self.grid = []
        num = 1
        
        for y in range(self.size):
            row = []
            for x in range(self.size):
                if y == self.size - 1 and x == self.size - 1:
                    row.append(0)  # Empty tile
                else:
                    row.append(num)
                    num += 1
            self.grid.append(row)
        
        self.empty_pos = (self.size - 1, self.size - 1)
    
    def shuffle(self):
        """Shuffle puzzle with valid moves"""
        for _ in range(100):
            moves = self.get_valid_moves()
            if moves:
                move = random.choice(moves)
                self.move_tile(move[0], move[1])
        
        self.moves = 0
    
    def get_valid_moves(self) -> List[Tuple[int, int]]:
        """Get list of valid tile positions that can move"""
        moves = []
        empty_y, empty_x = self.empty_pos
        
        # Check adjacent tiles
        for dy, dx in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
            new_y, new_x = empty_y + dy, empty_x + dx
            if 0 <= new_y < self.size and 0 <= new_x < self.size:
                moves.append((new_y, new_x))
        
        return moves
    
    def move_tile(self, y: int, x: int) -> bool:
        """Move tile to empty position"""
        empty_y, empty_x = self.empty_pos
        
        # Check if adjacent to empty
        if abs(y - empty_y) + abs(x - empty_x) != 1:
            return False
        
        # Swap tile with empty
        self.grid[empty_y][empty_x] = self.grid[y][x]
        self.grid[y][x] = 0
        self.empty_pos = (y, x)
        
        self.moves += 1
        self.check_win()
        
        return True
    
    def check_win(self):
        """Check if puzzle is solved"""
        expected = 1
        
        for y in range(self.size):
            for x in range(self.size):
                if y == self.size - 1 and x == self.size - 1:
                    if self.grid[y][x] != 0:
                        return
                else:
                    if self.grid[y][x] != expected:
                        return
                    expected += 1
        
        self.solved = True

# Physics Puzzle Implementation
class PhysicsPuzzle:
    """Physics-based puzzle game"""
    
    def __init__(self, screen_width: int, screen_height: int):
        self.width = screen_width
        self.height = screen_height
        self.gravity = 0.5
        self.objects = []
        self.projectile = None
        self.target = None
        self.obstacles = []
        self.solved = False
        
        self.create_level()
    
    def create_level(self):
        """Create a physics puzzle level"""
        # Create target
        self.target = {
            'x': self.width - 100,
            'y': self.height - 100,
            'radius': 30,
            'hit': False
        }
        
        # Create obstacles
        self.obstacles = [
            {
                'x': self.width // 2,
                'y': self.height - 200,
                'width': 20,
                'height': 150
            },
            {
                'x': self.width // 3,
                'y': self.height - 300,
                'width': 100,
                'height': 20
            }
        ]
        
        # Initialize projectile
        self.reset_projectile()
    
    def reset_projectile(self):
        """Reset projectile to launch position"""
        self.projectile = {
            'x': 50,
            'y': self.height - 50,
            'vx': 0,
            'vy': 0,
            'radius': 10,
            'launched': False,
            'trail': []
        }
    
    def launch(self, target_x: float, target_y: float):
        """Launch projectile towards target"""
        if self.projectile['launched']:
            return
        
        # Calculate launch velocity
        dx = target_x - self.projectile['x']
        dy = target_y - self.projectile['y']
        
        power = min(math.sqrt(dx**2 + dy**2) / 20, 15)
        angle = math.atan2(dy, dx)
        
        self.projectile['vx'] = math.cos(angle) * power
        self.projectile['vy'] = math.sin(angle) * power
        self.projectile['launched'] = True
    
    def update(self):
        """Update physics simulation"""
        if not self.projectile['launched'] or self.solved:
            return
        
        # Apply gravity
        self.projectile['vy'] += self.gravity
        
        # Update position
        self.projectile['x'] += self.projectile['vx']
        self.projectile['y'] += self.projectile['vy']
        
        # Add to trail
        self.projectile['trail'].append({
            'x': self.projectile['x'],
            'y': self.projectile['y']
        })
        
        if len(self.projectile['trail']) > 50:
            self.projectile['trail'].pop(0)
        
        # Check collisions with obstacles
        for obstacle in self.obstacles:
            if (self.projectile['x'] + self.projectile['radius'] > obstacle['x'] and
                self.projectile['x'] - self.projectile['radius'] < obstacle['x'] + obstacle['width'] and
                self.projectile['y'] + self.projectile['radius'] > obstacle['y'] and
                self.projectile['y'] - self.projectile['radius'] < obstacle['y'] + obstacle['height']):
                
                # Simple bounce
                if abs(self.projectile['vx']) > abs(self.projectile['vy']):
                    self.projectile['vx'] *= -0.8
                else:
                    self.projectile['vy'] *= -0.8
        
        # Check collision with target
        dx = self.projectile['x'] - self.target['x']
        dy = self.projectile['y'] - self.target['y']
        dist = math.sqrt(dx**2 + dy**2)
        
        if dist < self.target['radius'] + self.projectile['radius']:
            self.target['hit'] = True
            self.solved = True
        
        # Reset if out of bounds
        if (self.projectile['y'] > self.height or
            self.projectile['x'] < 0 or
            self.projectile['x'] > self.width):
            self.reset_projectile()

# Pattern Memory Puzzle
class PatternPuzzle:
    """Pattern memory puzzle game"""
    
    def __init__(self, grid_size: int = 4):
        self.grid_size = grid_size
        self.pattern = []
        self.player_pattern = []
        self.showing_pattern = False
        self.level = 1
        self.score = 0
        
        self.generate_pattern()
    
    def generate_pattern(self):
        """Generate a new pattern to memorize"""
        pattern_length = 3 + self.level
        self.pattern = []
        
        for _ in range(pattern_length):
            self.pattern.append({
                'x': random.randint(0, self.grid_size - 1),
                'y': random.randint(0, self.grid_size - 1)
            })
        
        self.player_pattern = []
        self.showing_pattern = True
    
    def add_player_input(self, x: int, y: int):
        """Add player's input to pattern"""
        if self.showing_pattern:
            return
        
        self.player_pattern.append({'x': x, 'y': y})
        
        # Check if pattern is complete
        if len(self.player_pattern) == len(self.pattern):
            if self.check_pattern():
                self.score += 100 * self.level
                self.level += 1
                self.generate_pattern()
            else:
                # Wrong pattern
                self.player_pattern = []
    
    def check_pattern(self) -> bool:
        """Check if player's pattern matches"""
        for i in range(len(self.pattern)):
            if (self.pattern[i]['x'] != self.player_pattern[i]['x'] or
                self.pattern[i]['y'] != self.player_pattern[i]['y']):
                return False
        return True

# Logic Gates Puzzle
class LogicGate:
    """Single logic gate"""
    
    def __init__(self, gate_type: str, x: int, y: int):
        self.type = gate_type  # AND, OR, NOT, XOR, NAND, NOR
        self.x = x
        self.y = y
        self.inputs = []
        self.output = False
        self.connected_to = None
    
    def evaluate(self):
        """Evaluate gate output based on inputs"""
        if self.type == "AND":
            self.output = all(self.inputs) if self.inputs else False
        elif self.type == "OR":
            self.output = any(self.inputs) if self.inputs else False
        elif self.type == "NOT":
            self.output = not self.inputs[0] if self.inputs else True
        elif self.type == "XOR":
            if len(self.inputs) == 2:
                self.output = self.inputs[0] != self.inputs[1]
            else:
                self.output = False
        elif self.type == "NAND":
            self.output = not all(self.inputs) if self.inputs else True
        elif self.type == "NOR":
            self.output = not any(self.inputs) if self.inputs else True

class LogicPuzzle:
    """Logic gates puzzle game"""
    
    def __init__(self):
        self.gates = []
        self.inputs = []
        self.outputs = []
        self.wires = []
        self.target_output = []
        self.solved = False
        
        self.create_puzzle()
    
    def create_puzzle(self):
        """Create a logic puzzle"""
        # Create input switches
        self.inputs = [
            {'id': 'A', 'value': False, 'x': 50, 'y': 100},
            {'id': 'B', 'value': False, 'x': 50, 'y': 200},
            {'id': 'C', 'value': False, 'x': 50, 'y': 300}
        ]
        
        # Create logic gates
        self.gates = [
            LogicGate("AND", 200, 150),
            LogicGate("OR", 200, 250),
            LogicGate("NOT", 350, 200)
        ]
        
        # Set target output
        self.target_output = [True]  # Example target
        
        # Create output
        self.outputs = [
            {'id': 'OUT', 'value': False, 'x': 500, 'y': 200}
        ]
    
    def toggle_input(self, input_id: str):
        """Toggle input switch"""
        for inp in self.inputs:
            if inp['id'] == input_id:
                inp['value'] = not inp['value']
                self.evaluate_circuit()
                break
    
    def connect_wire(self, from_obj, to_obj):
        """Connect two objects with a wire"""
        self.wires.append({
            'from': from_obj,
            'to': to_obj
        })
        self.evaluate_circuit()
    
    def evaluate_circuit(self):
        """Evaluate the entire circuit"""
        # Propagate signals through gates
        for gate in self.gates:
            gate.evaluate()
        
        # Check if puzzle is solved
        output_values = [out['value'] for out in self.outputs]
        if output_values == self.target_output:
            self.solved = True

# Main Puzzle Game Manager
class PuzzleGameManager:
    """Manages different puzzle types"""
    
    def __init__(self, screen_width: int = 800, screen_height: int = 600):
        pygame.init()
        self.screen = pygame.display.set_mode((screen_width, screen_height))
        pygame.display.set_caption("Puzzle Games")
        self.clock = pygame.time.Clock()
        
        self.width = screen_width
        self.height = screen_height
        
        # Initialize puzzles
        self.puzzles = {
            PuzzleType.MATCH3: Match3Game(),
            PuzzleType.SLIDING: SlidingPuzzle(),
            PuzzleType.PHYSICS: PhysicsPuzzle(screen_width, screen_height),
            PuzzleType.PATTERN: PatternPuzzle(),
            PuzzleType.LOGIC: LogicPuzzle()
        }
        
        self.current_puzzle = PuzzleType.MATCH3
        self.active_puzzle = self.puzzles[self.current_puzzle]
        
        # UI state
        self.score = 0
        self.time_elapsed = 0
        self.moves = 0
    
    def switch_puzzle(self, puzzle_type: PuzzleType):
        """Switch to a different puzzle type"""
        self.current_puzzle = puzzle_type
        self.active_puzzle = self.puzzles[puzzle_type]
        self.score = 0
        self.moves = 0
        self.time_elapsed = 0
    
    def handle_click(self, pos: Tuple[int, int]):
        """Handle mouse click"""
        x, y = pos
        
        if self.current_puzzle == PuzzleType.MATCH3:
            # Convert to grid coordinates
            grid_x = x // 60
            grid_y = y // 60
            
            if 0 <= grid_x < 8 and 0 <= grid_y < 8:
                match3 = self.active_puzzle
                if match3.selected:
                    # Try to swap
                    prev_x, prev_y = match3.selected
                    if match3.swap_gems(prev_x, prev_y, grid_x, grid_y):
                        self.score = match3.score
                        self.moves = match3.moves
                    match3.selected = None
                else:
                    match3.selected = (grid_x, grid_y)
        
        elif self.current_puzzle == PuzzleType.SLIDING:
            # Convert to grid coordinates
            grid_x = (x - 200) // 100
            grid_y = (y - 100) // 100
            
            if 0 <= grid_x < 4 and 0 <= grid_y < 4:
                if self.active_puzzle.move_tile(grid_y, grid_x):
                    self.moves = self.active_puzzle.moves
                    if self.active_puzzle.solved:
                        self.score += 1000
        
        elif self.current_puzzle == PuzzleType.PHYSICS:
            self.active_puzzle.launch(x, y)
        
        elif self.current_puzzle == PuzzleType.PATTERN:
            grid_x = x // 100
            grid_y = y // 100
            
            if 0 <= grid_x < 4 and 0 <= grid_y < 4:
                self.active_puzzle.add_player_input(grid_x, grid_y)
                self.score = self.active_puzzle.score
    
    def render_match3(self):
        """Render Match-3 puzzle"""
        match3 = self.active_puzzle
        
        for y in range(match3.grid_size):
            for x in range(match3.grid_size):
                gem_type = match3.grid[y][x]
                if gem_type != -1:
                    color = match3.gem_colors[gem_type]
                    pygame.draw.circle(
                        self.screen,
                        color,
                        (x * 60 + 30, y * 60 + 30),
                        25
                    )
                    
                    # Highlight selected
                    if match3.selected == (x, y):
                        pygame.draw.circle(
                            self.screen,
                            (255, 255, 255),
                            (x * 60 + 30, y * 60 + 30),
                            25,
                            3
                        )
    
    def render_sliding(self):
        """Render sliding puzzle"""
        puzzle = self.active_puzzle
        
        for y in range(puzzle.size):
            for x in range(puzzle.size):
                value = puzzle.grid[y][x]
                
                if value != 0:
                    rect = pygame.Rect(
                        200 + x * 100,
                        100 + y * 100,
                        95,
                        95
                    )
                    
                    pygame.draw.rect(self.screen, (100, 150, 200), rect)
                    
                    font = pygame.font.Font(None, 48)
                    text = font.render(str(value), True, (255, 255, 255))
                    text_rect = text.get_rect(center=rect.center)
                    self.screen.blit(text, text_rect)
    
    def render_physics(self):
        """Render physics puzzle"""
        physics = self.active_puzzle
        
        # Draw target
        color = (0, 255, 0) if physics.target['hit'] else (255, 0, 0)
        pygame.draw.circle(
            self.screen,
            color,
            (int(physics.target['x']), int(physics.target['y'])),
            physics.target['radius']
        )
        
        # Draw obstacles
        for obstacle in physics.obstacles:
            pygame.draw.rect(
                self.screen,
                (128, 128, 128),
                (obstacle['x'], obstacle['y'], obstacle['width'], obstacle['height'])
            )
        
        # Draw projectile
        if physics.projectile:
            pygame.draw.circle(
                self.screen,
                (255, 255, 0),
                (int(physics.projectile['x']), int(physics.projectile['y'])),
                physics.projectile['radius']
            )
            
            # Draw trail
            for point in physics.projectile['trail']:
                pygame.draw.circle(
                    self.screen,
                    (255, 255, 0, 128),
                    (int(point['x']), int(point['y'])),
                    2
                )
    
    def render_ui(self):
        """Render UI elements"""
        font = pygame.font.Font(None, 36)
        
        # Score
        score_text = font.render(f"Score: {self.score}", True, (255, 255, 255))
        self.screen.blit(score_text, (10, 10))
        
        # Moves
        moves_text = font.render(f"Moves: {self.moves}", True, (255, 255, 255))
        self.screen.blit(moves_text, (10, 50))
        
        # Current puzzle type
        puzzle_text = font.render(f"Puzzle: {self.current_puzzle.value}", True, (255, 255, 255))
        self.screen.blit(puzzle_text, (10, 90))
    
    def run(self):
        """Main game loop"""
        running = True
        
        while running:
            dt = self.clock.tick(60) / 1000.0
            self.time_elapsed += dt
            
            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
                
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    if event.button == 1:  # Left click
                        self.handle_click(event.pos)
                
                elif event.type == pygame.KEYDOWN:
                    # Switch puzzles with number keys
                    if event.key == pygame.K_1:
                        self.switch_puzzle(PuzzleType.MATCH3)
                    elif event.key == pygame.K_2:
                        self.switch_puzzle(PuzzleType.SLIDING)
                    elif event.key == pygame.K_3:
                        self.switch_puzzle(PuzzleType.PHYSICS)
                    elif event.key == pygame.K_4:
                        self.switch_puzzle(PuzzleType.PATTERN)
                    elif event.key == pygame.K_5:
                        self.switch_puzzle(PuzzleType.LOGIC)
            
            # Update
            if self.current_puzzle == PuzzleType.PHYSICS:
                self.active_puzzle.update()
            
            # Render
            self.screen.fill((30, 30, 50))
            
            if self.current_puzzle == PuzzleType.MATCH3:
                self.render_match3()
            elif self.current_puzzle == PuzzleType.SLIDING:
                self.render_sliding()
            elif self.current_puzzle == PuzzleType.PHYSICS:
                self.render_physics()
            
            self.render_ui()
            
            pygame.display.flip()
        
        pygame.quit()

if __name__ == "__main__":
    game = PuzzleGameManager()
    game.run()

Key Features Implemented

✅ Complete Puzzle Systems

Usage Instructions

🎮 How to Play

  1. Install Pygame: pip install pygame
  2. Run the game: python puzzle_game.py
  3. Controls:
    • Number keys 1-5 to switch puzzle types
    • Mouse click for all interactions
    • Match-3: Click to select, click adjacent to swap
    • Sliding: Click tile to move into empty space
    • Physics: Click to launch projectile

Customization Options

🔧 Easy Modifications

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Three Pythonic Levers, One Demo — Enum Puzzle Identity + 2D-Array Board State + Recursive Cascade Fixed-Point in One Pygame Window

Objective: Build a runnable pygame program (~110 lines) that distills the lesson's class PuzzleType(Enum) + Match3Game / SlidingPuzzle / PhysicsPuzzle / PatternPuzzle / LogicPuzzle five-class architecture into one cohesive demo where three Python-specific puzzle disciplines are visible per frame on a 1088×540 window split into three labeled vertical panels: LEFT (10–190) renders the five PuzzleType Enum members as an identity selector with the active member highlighted in yellow and keys 1–5 binding to active = PuzzleType.MATCH3 through PuzzleType.LOGIC; MIDDLE (200–740) renders the active puzzle's 2D-array board through direct nested-list reads match3.grid[r][c] and sliding.grid[r][c]; RIGHT (750–1078) renders the recursive-cascade trace built by Match3.process(matches, depth) calling itself when find_matches() returns a non-empty set, with combo and depth counters reflecting the current call-stack frame and a rolling 15-line log of every recursive entry. (a) Enum-based puzzle-type identity at PUZZLE-IDENTITY scope: the dispatch keys ARE PuzzleType.MATCH3 through PuzzleType.LOGIC Enum members, not bare strings; a typo at the dispatch site like PuzzleType.MATC3 raises AttributeError at the import statement of the typo-containing module, while a bare-string typo like puzzles['matc3'] would silently return None at runtime when dispatch happens; mypy can also verify active: PuzzleType annotations at design time and reject any string literal that doesn't match a declared member. Same design-time-validation-vs-runtime-discovery pattern as chat-47 platformer_level_design's validate-vs-is_solid + chat-70 publishing_executables's hiddenimports static-AST-vs-runtime-discovery + chat-72 publishing_performance's profile-bars-vs-intuition + chat-73 publishing_platforms's try-import runtime-capability-detection applied at PUZZLE-IDENTITY scope = FIFTH lesson reinforcing the design-time-validation-vs-runtime-discovery pattern across Phase 8, FIRST applied at PUZZLE-IDENTITY scope. (b) 2D-array board state at BOARD-REPRESENTATION scope: Match3's self.grid is [[int]] 8×8 with values 0–4 plus -1 for cleared cells, and Sliding's self.grid is [[int]] 4×4 with values 0–15 (0 = empty slot); both grid-based puzzle classes share the nested-list-of-ints shape so swap is a two-line index-pair assignment, win-check is a flat scan, match-detect is two nested for-loops over (r, c), and gravity-drop is a single-column list comprehension [self.grid[r][c] for r in range(GRID) if self.grid[r][c] >= 0]; same data-driven externalization pattern as architecture_save_load schema chat-58 + pathfinding terrain-cost dict chat-60 + behavior-trees child-list-order chat-61 + decision-making personality dict chat-62 + procedural generation seed chat-68 + publishing_executables .spec file chat-70 + publishing_marketing pitch templates chat-71 + publishing_performance algorithmic-axis toggles chat-72 + publishing_platforms TARGETS+ADAPTERS dicts chat-73 + publishing_updates CADENCES+MIGRATIONS dicts chat-74 + genres_puzzle 2D-array board chat-75 applied at BOARD-REPRESENTATION scope = EIGHTEENTH lesson reinforcing data-driven externalization across Phase 8, REAFFIRMING chat-75's first BOARD-REPRESENTATION application now in Python where the from dataclasses import dataclass import is unused and the board IS raw [[int]]. (c) Recursive cascade fixed-point at MATCH-CASCADE scope: Match3.process(matches, depth=1) recursively calls itself with new = self.find_matches(); if new: self.combo += 1; self.process(new, depth + 1); the depth parameter threads the call-stack count through to the visualization so each recursive entry is visible as 'process_matches depth=N cleared=K' in the rolling log; same chained-contract pattern as chat-74 publishing_updates' MIGRATIONS chain (each migration step is a frozen contract that may produce a new state requiring another step) applied at MATCH-CASCADE scope where each recursive call is a frozen contract that may produce new matches requiring another call, expressed as Python-natural recursion rather than chat-75's iterative combo = 0; while self.step(): combo += 1 shape; the recursion terminates because clearing strictly reduces the count of non-cleared cells in the finite-state grid so a base case (find_matches() returns empty → no recursive call) must be reached. SPACE plants a deliberate horizontal triple in a random row and triggers a fresh cascade by calling find_matches() on the modified grid then entering recursion if matches exist.

Instructions:

  1. Set up a 1088×540 pygame window with three vertical panels at x=10–190 (Identity), x=200–740 (Board), x=750–1078 (Recursion Log).
  2. Define class PuzzleType(Enum) with five members MATCH3 / SLIDING / PHYSICS / PATTERN / LOGIC matching the lesson's enum.
  3. Define Match3 class with self.grid = [[random.randint(0, 4) for _ in range(8)] for _ in range(8)], find_matches() returning a set of (r, c) tuples by scanning rows then columns for triples, process(matches, depth=1) recursive method that clears matched cells, applies gravity via per-column list comprehension, and self-calls when new matches surface, and trigger() that plants a deliberate horizontal triple then enters the recursion if matches exist.
  4. Define Sliding class with self.grid 4×4 holding shuffled 0–15 (0 = empty slot).
  5. Build the PUZZLES = {PuzzleType.MATCH3: Match3(), PuzzleType.SLIDING: Sliding(), ...} dict keyed by Enum members; an active variable tracks current PuzzleType.
  6. In the event loop bind keys 1–5 to set active = PuzzleType.MATCH3 etc. via a KEYS dict that maps pygame.K_1 through pygame.K_5 to Enum members, and bind SPACE to call PUZZLES[PuzzleType.MATCH3].trigger() when active is MATCH3.
  7. Per frame render the three panels: LEFT iterates for i, t in enumerate(PuzzleType) highlighting t == active in yellow; MIDDLE branches on active rendering match3.grid as colored 50×50 squares for cells with v >= 0 and sliding.grid as numbered 90×90 tiles for cells with v != 0; RIGHT shows match3.combo, match3.depth, and the rolling 15-line match3.log in 22px line spacing when active is MATCH3.
💡 Hint

The three axes are independent levers visible in three different panels. The LEFT panel iterates for i, t in enumerate(PuzzleType) reading the Enum members directly — switching active is just rebinding active = PuzzleType.SLIDING at the keypress, and the dispatch in the MIDDLE and RIGHT panels reads active to decide what to render. The MIDDLE panel reads the 2D-array boards directly via match3.grid[r][c] and sliding.grid[r][c] — adding a sixth puzzle type with its own 2D-array board would not change how match3 and sliding render. The RIGHT panel's depth counter is set by the depth parameter that recursive process() threads through itself — when SPACE is pressed and find_matches() returns non-empty, process(matches, 1) runs its body once at depth 1 then if find_matches() returns non-empty again calls process(next_matches, 2), and so on until a base case is reached (find_matches() returns empty) — the call stack IS the combo counter without any explicit accumulator outside the recursion.

✅ Example Solution
import pygame, random, sys
from enum import Enum

W, H = 1088, 540
GRID, CELL = 8, 50

class PuzzleType(Enum):
    MATCH3 = "match3"
    SLIDING = "sliding"
    PHYSICS = "physics"
    PATTERN = "pattern"
    LOGIC = "logic"

class Match3:
    def __init__(self):
        self.grid = [[random.randint(0, 4) for _ in range(GRID)] for _ in range(GRID)]
        self.combo = 0
        self.depth = 0
        self.log = []
    def find_matches(self):
        m = set()
        for r in range(GRID):
            for c in range(GRID - 2):
                v = self.grid[r][c]
                if v >= 0 and v == self.grid[r][c+1] == self.grid[r][c+2]:
                    m.update([(r, c), (r, c+1), (r, c+2)])
        for c in range(GRID):
            for r in range(GRID - 2):
                v = self.grid[r][c]
                if v >= 0 and v == self.grid[r+1][c] == self.grid[r+2][c]:
                    m.update([(r, c), (r+1, c), (r+2, c)])
        return m
    def process(self, matches, depth=1):
        self.depth = depth
        self.log.append(f"process_matches depth={depth} cleared={len(matches)}")
        for r, c in matches:
            self.grid[r][c] = -1
        for c in range(GRID):
            col = [self.grid[r][c] for r in range(GRID) if self.grid[r][c] >= 0]
            col = [random.randint(0, 4) for _ in range(GRID - len(col))] + col
            for r in range(GRID):
                self.grid[r][c] = col[r]
        new = self.find_matches()
        if new:
            self.combo += 1
            self.process(new, depth + 1)
    def trigger(self):
        r = random.randint(0, GRID - 1)
        c = random.randint(0, GRID - 3)
        v = random.randint(0, 4)
        self.grid[r][c] = self.grid[r][c+1] = self.grid[r][c+2] = v
        self.combo, self.log = 1, []
        m = self.find_matches()
        if m:
            self.process(m, 1)

class Sliding:
    def __init__(self):
        n = list(range(1, 16)) + [0]
        random.shuffle(n)
        self.grid = [n[i*4:(i+1)*4] for i in range(4)]

class Physics: pass
class Pattern: pass
class Logic: pass

PUZZLES = {
    PuzzleType.MATCH3: Match3(),
    PuzzleType.SLIDING: Sliding(),
    PuzzleType.PHYSICS: Physics(),
    PuzzleType.PATTERN: Pattern(),
    PuzzleType.LOGIC: Logic(),
}
COLORS = [(255,80,80),(80,200,80),(80,160,255),(240,200,60),(200,80,255)]

pygame.init()
scr = pygame.display.set_mode((W, H))
font = pygame.font.SysFont(None, 22)
big = pygame.font.SysFont(None, 28)
clk = pygame.time.Clock()
active = PuzzleType.MATCH3
KEYS = {pygame.K_1: PuzzleType.MATCH3, pygame.K_2: PuzzleType.SLIDING,
        pygame.K_3: PuzzleType.PHYSICS, pygame.K_4: PuzzleType.PATTERN,
        pygame.K_5: PuzzleType.LOGIC}
run = True
while run:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            run = False
        elif e.type == pygame.KEYDOWN:
            if e.key in KEYS:
                active = KEYS[e.key]
            elif e.key == pygame.K_SPACE and active == PuzzleType.MATCH3:
                PUZZLES[PuzzleType.MATCH3].trigger()
    scr.fill((20, 22, 30))
    pygame.draw.rect(scr, (40, 44, 55), (10, 10, 180, 520))
    scr.blit(big.render("Identity", True, (255,255,255)), (20, 18))
    for i, t in enumerate(PuzzleType):
        col = (255, 220, 80) if t == active else (180, 180, 180)
        scr.blit(font.render(f"{i+1}: {t.name}", True, col), (20, 60 + i*30))
    pygame.draw.rect(scr, (40, 44, 55), (200, 10, 540, 520))
    scr.blit(big.render(f"Board ({active.name})", True, (255,255,255)), (210, 18))
    if active == PuzzleType.MATCH3:
        m3 = PUZZLES[PuzzleType.MATCH3]
        for r in range(GRID):
            for c in range(GRID):
                v = m3.grid[r][c]
                if v >= 0:
                    pygame.draw.rect(scr, COLORS[v], (220+c*CELL, 60+r*CELL, CELL-4, CELL-4))
    elif active == PuzzleType.SLIDING:
        sl = PUZZLES[PuzzleType.SLIDING]
        for r in range(4):
            for c in range(4):
                v = sl.grid[r][c]
                if v != 0:
                    pygame.draw.rect(scr, (80, 160, 255), (260+c*100, 90+r*100, 90, 90))
                    scr.blit(big.render(str(v), True, (255,255,255)), (260+c*100+30, 90+r*100+30))
    pygame.draw.rect(scr, (40, 44, 55), (750, 10, 328, 520))
    scr.blit(big.render("Recursion Log", True, (255,255,255)), (760, 18))
    if active == PuzzleType.MATCH3:
        m3 = PUZZLES[PuzzleType.MATCH3]
        scr.blit(font.render(f"combo: {m3.combo}", True, (255,220,80)), (760, 55))
        scr.blit(font.render(f"depth: {m3.depth}", True, (255,220,80)), (760, 80))
        for i, line in enumerate(m3.log[-15:]):
            scr.blit(font.render(line, True, (200,200,200)), (760, 110+i*22))
    pygame.display.flip()
    clk.tick(60)
pygame.quit()
sys.exit()

🎯 Quick Quiz

Question 1: The lesson's class PuzzleType(Enum) declares MATCH3/SLIDING/PHYSICS/PATTERN/LOGIC and the manager's puzzles dict is keyed by these Enum members rather than by bare strings 'match3'/'sliding'/etc. as in chat-75's JS implementation. What is the architectural reason for choosing Enum keys over string keys for puzzle identifiers?

Question 2: Match3.grid is a nested list of ints (8×8 with values 0–4 plus -1 for cleared cells) and Sliding.grid is a nested list of ints (4×4 with values 0–15 with 0 = empty). Both grid-based puzzle classes use the same [[int]] shape. What is the architectural reason for choosing raw nested lists rather than parallel Cell objects with row/col/color attributes?

Question 3: Match3.process calls itself recursively when find_matches() returns non-empty: new = self.find_matches(); if new: self.combo += 1; self.process(new, depth + 1). The chat-75 JS implementation expressed the same fixed-point iteration as combo = 0; while self.step(): combo += 1. What is the architectural reason this pygame demo expresses the cascade as recursion rather than as iteration?