Skip to main content

Puzzle Game Logic

20 minute read

Building Engaging Puzzle Game Mechanics

Master puzzle game design! Create match-3 mechanics, sliding puzzles, physics-based challenges, pattern matching, and logic gates! 🧩💎🔮

Understanding Puzzle Game Systems

🎯 Core Puzzle Mechanics

Great puzzle games combine simple rules with emergent complexity:

graph TD A["Puzzle Game Core"] --> B["Input System"] A --> C["Game Rules"] A --> D["Win Conditions"] A --> E["Feedback System"] B --> F["Click/Touch"] B --> G["Drag & Drop"] B --> H["Keyboard"] C --> I["Valid Moves"] C --> J["Constraints"] C --> K["Scoring"] D --> L["Clear Board"] D --> M["Target Score"] D --> N["Time Limit"] E --> O["Visual Effects"] E --> P["Sound Cues"] E --> Q["Progress Indicators"]
Five puzzle mechanics shown side by side as static mid-play snapshots. Top row: Match-3 (4×4 gem grid with one gem ringed amber as the selected swap and a row of three ambers highlighted by a green pill as a match in progress); Sliding (15-puzzle with one empty slot and a left-arrow on the next tile to slide); Physics (cannon, dashed parabolic arc with a red projectile at apex, three-ring bullseye target). Bottom row: Pattern (2×2 Simon-style buttons with the blue top-right button lit and ringed with a glow); Logic Gates (AND gate with both inputs A and B and the output Q all green, annotated "= 1").
Five core puzzle mechanics — match-3, sliding tiles, projectile physics, pattern memory, and logic gates — shown side by side as static snapshots. Each cell mirrors one of the canvas demo's interactive modes; the highlighted states (the selection ring, the match-in-progress pill, the lit Simon button, the green wires) freeze a single representative moment from each puzzle so the diagram can stand alongside the live demo without depending on JavaScript.

Interactive Multi-Puzzle Demo

Five puzzle mechanics shown side by side: match-3, sliding tiles, projectile arc, pattern memory, and AND-gate logic.
Five core puzzle mechanics shown side by side: match-3 grid, sliding tiles, projectile arc, pattern sequence, and an AND-gate. The interactive demo lets you switch between all five; this diagram shows representative states from each.

Experience different puzzle mechanics - Match gems, slide tiles, launch projectiles, and solve logic gates!

Select Puzzle Type:

Match-3: Click to select, click adjacent gem to swap

🏆 Score: 0
🎯 Moves: 0
⏱️ Time: 0:00
⚡ Combo: 1x

Core Puzzle Types Explained

🎮 Essential Puzzle Mechanics

1. Match-3 Mechanics

2. Sliding Puzzles

3. Physics Puzzles

4. Pattern Matching

5. Logic Gates

Puzzle Game Implementation in Python

🐍 Complete Python Implementation

For the full Python implementation with Pygame including all puzzle types, advanced features, and procedural generation, Python code.

Key Implementation Features:

Best Practices

✨ Puzzle Game Best Practices

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Five Genres, One Interface — Polymorphic Puzzle-Genre Dispatch + 2D-Array Board State + Match-Cascade Fixed-Point Loop in One Pygame Window

Objective: Build a runnable pygame window in roughly 95 lines that shows three orthogonal puzzle-game disciplines visible per frame on a 1088×540 window with three labeled regions: LEFT panel renders a 5-row genre selector with the active genre highlighted; MIDDLE panel renders the active puzzle's board state via the same render(surface, ox, oy) method on every genre class; RIGHT panel shows the live combo counter and a rolling 8-line dispatch log of every event routed through the polymorphic interface. (a) Polymorphic puzzle-genre dispatch at PUZZLE-GENRE scope — a PUZZLES = {'match3': Match3(), 'sliding': Sliding(), 'physics': Physics(), 'pattern': Pattern(), 'logic': Logic()} dict plus a uniform handle_click / step / render interface across all five classes lets the controller call PUZZLES[active].handle_click(mx, my) and PUZZLES[active].render(screen, ox, oy) with zero if active == 'match3': ... elif active == 'sliding': ... branches; adding a sixth genre (e.g., word puzzles) is one new class plus one new dict entry with no controller edits. Same UIElement-protocol pattern from chat-67 graphics_ui_hud and PlatformAdapter pattern from chat-73 publishing_platforms applied at PUZZLE-GENRE scope = SECOND polymorphic-dispatch lesson reinforcing the shared-protocol pattern at a new domain scope. (b) Grid-state-as-2D-array at BOARD-REPRESENTATION scope — match-3 stores its 8×8 board as grid[r][c] = color_int with values 0–5 plus −1 for cleared cells, and sliding stores its 4×4 board as grid[r][c] = number with values 1–15 plus 0 for the empty cell; both genres share the same 2D-array shape so swap (match-3) and slide (sliding) are both two-line index-pair assignments, win-check is a flat scan, match-detect is two nested for-loops over (r, c), and serializing the board for save/undo is one json.dumps call. 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 dict (chat-73), and publishing_updates CADENCES + MIGRATIONS dicts (chat-74) applied at BOARD-REPRESENTATION scope = SEVENTEENTH lesson reinforcing data-driven externalization across Phase 8, FIRST applied at BOARD-REPRESENTATION scope where the board IS the data the puzzle's algorithms (swap, slide, match-detect, cascade) read and mutate. (c) Match-cascade-as-fixed-point-loop at MATCH-CASCADE scope — match-3's handle_click ends with combo = 0; while self.step(): combo += 1 where step() performs {detect runs of 3+ → mark cleared cells → drop survivors → spawn new from top → return True if any change happened, False if state stabilized}; the loop terminates when no further matches exist and the combo multiplier IS the iteration count without any extra accumulator. Same chained-contract pattern as chat-74 publishing_updates' MIGRATIONS dict (each iteration is a frozen contract that may produce a new state requiring another iteration) applied at MATCH-CASCADE scope. Keys 1/2/3/4/5 switch the active genre; mouse clicks dispatch through the active class via PUZZLES[active].handle_click(...); HUD shows combo counter and dispatch log — three orthogonal puzzle-game disciplines visible per frame as concrete dict entries, board values, and iteration counts.

Instructions:

  1. Open a 1088×540 pygame window with three labeled regions (LEFT genre selector at x=10–190, MIDDLE puzzle board at x=200–760, RIGHT combo+log at x=770–1078).
  2. Define five puzzle classes Match3 / Sliding / Physics / Pattern / Logic, each implementing the same handle_click(local_x, local_y) and render(surf, ox, oy) method names, plus step() on every class returning True if a cascade-style state change occurred (only Match3 returns True; the other four always return False).
  3. Store the match-3 board as a 2D list grid[r][c] with int values 0–5 (color index) and −1 for cleared cells; store the sliding board as a 2D list grid[r][c] with int values 0–15 (0 = empty) — same 2D-array shape, different value domain.
  4. In Match3.step(), scan the grid for horizontal and vertical runs of 3+ same color, mark them, set marked cells to −1, drop surviving cells with column-rebuilding, spawn random colors into the empty slots at the top of each column, return True if any cells were marked this pass and False otherwise.
  5. In Match3.handle_click, after a valid adjacent swap, run combo = 0; while self.step(): combo += 1 and store the final combo as self.last_combo for the HUD to display — the combo multiplier IS the number of cascade iterations.
  6. Build a controller dict PUZZLES = {'match3': Match3(), 'sliding': Sliding(), 'physics': Physics(), 'pattern': Pattern(), 'logic': Logic()} and dispatch every controller event with PUZZLES[active].method(...) — never branch on the active string anywhere in the controller.
  7. On keys 1–5 switch active to the corresponding genre name and append a 'switch <name>' line to the rolling 8-line log; on mouse click in the middle panel, dispatch through PUZZLES[active].handle_click(mx-200, my-60) and append a 'click on <name>' line.
💡 Hint

The three axes are independent levers: the dispatch dict (axis a) controls WHICH class handles an event with no controller branching; the 2D-array board shape (axis b) controls HOW each board's state is stored and gives match-3 and sliding the same algorithmic primitive (index-pair assignment) for swap and slide; the fixed-point cascade loop (axis c) lets match-3 run multi-step combos with the iteration count itself acting as the combo multiplier without any extra accumulator. Keep the controller code (event loop + render orchestrator) free of any if active == '...' branches — every controller-side operation goes through PUZZLES[active].method(...). The step() method's return value is the loop's halting condition AND the source of the combo count: the loop runs until step() returns False, and every True return increments the combo by exactly one.

✅ Example Solution
import pygame, sys, random
pygame.init()
W, H = 1088, 540
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption('Five Genres, One Interface')
font = pygame.font.SysFont('consolas', 14)
big = pygame.font.SysFont('consolas', 22, bold=True)
clock = pygame.time.Clock()
COLORS = [(220,80,80),(80,200,80),(80,140,220),(220,200,80),(200,80,200),(80,200,200)]

class Match3:
    def __init__(self):
        self.grid = [[random.randint(0,5) for _ in range(8)] for _ in range(8)]
        self.sel = None; self.last_combo = 0
    def handle_click(self, lx, ly):
        c, r = lx // 40, ly // 40
        if not (0 <= c < 8 and 0 <= r < 8): return
        if self.sel is None: self.sel = (r, c); return
        r0, c0 = self.sel; self.sel = None
        if abs(r0-r) + abs(c0-c) != 1: return
        self.grid[r0][c0], self.grid[r][c] = self.grid[r][c], self.grid[r0][c0]
        combo = 0
        while self.step(): combo += 1
        self.last_combo = combo
    def step(self):
        m = [[False]*8 for _ in range(8)]; hit = False
        for r in range(8):
            for c in range(6):
                v = self.grid[r][c]
                if v >= 0 and v == self.grid[r][c+1] == self.grid[r][c+2]:
                    m[r][c] = m[r][c+1] = m[r][c+2] = True; hit = True
        for c in range(8):
            for r in range(6):
                v = self.grid[r][c]
                if v >= 0 and v == self.grid[r+1][c] == self.grid[r+2][c]:
                    m[r][c] = m[r+1][c] = m[r+2][c] = True; hit = True
        if not hit: return False
        for r in range(8):
            for c in range(8):
                if m[r][c]: self.grid[r][c] = -1
        for c in range(8):
            col = [self.grid[r][c] for r in range(8) if self.grid[r][c] != -1]
            col = [-1]*(8-len(col)) + col
            for r in range(8): self.grid[r][c] = col[r]
        for r in range(8):
            for c in range(8):
                if self.grid[r][c] == -1: self.grid[r][c] = random.randint(0,5)
        return True
    def render(self, surf, ox, oy):
        for r in range(8):
            for c in range(8):
                v = self.grid[r][c]
                col = COLORS[v%6] if v >= 0 else (40,40,40)
                pygame.draw.rect(surf, col, (ox+c*40+1, oy+r*40+1, 38, 38))
        if self.sel:
            r, c = self.sel
            pygame.draw.rect(surf, (255,255,255), (ox+c*40, oy+r*40, 40, 40), 2)

class Sliding:
    def __init__(self):
        self.grid = [[r*4+c for c in range(4)] for r in range(4)]
        for _ in range(100): self._slide_random()
        self.last_combo = 0
    def _empty(self):
        for r in range(4):
            for c in range(4):
                if self.grid[r][c] == 0: return r, c
    def _slide_random(self):
        er, ec = self._empty()
        opts = [(r,c) for r,c in [(er-1,ec),(er+1,ec),(er,ec-1),(er,ec+1)] if 0<=r<4 and 0<=c<4]
        nr, nc = random.choice(opts)
        self.grid[er][ec], self.grid[nr][nc] = self.grid[nr][nc], self.grid[er][ec]
    def handle_click(self, lx, ly):
        c, r = lx // 80, ly // 80
        if not (0<=c<4 and 0<=r<4): return
        er, ec = self._empty()
        if abs(er-r) + abs(ec-c) == 1:
            self.grid[er][ec], self.grid[r][c] = self.grid[r][c], self.grid[er][ec]
    def step(self): return False
    def render(self, surf, ox, oy):
        for r in range(4):
            for c in range(4):
                v = self.grid[r][c]
                col = (40,40,40) if v == 0 else (100,160,220)
                pygame.draw.rect(surf, col, (ox+c*80+1, oy+r*80+1, 78, 78))
                if v != 0:
                    surf.blit(big.render(str(v), True, (255,255,255)), (ox+c*80+28, oy+r*80+28))

class Physics:
    def __init__(self):
        self.x, self.y, self.vx, self.vy, self.flying, self.last_combo = 50, 250, 0, 0, False, 0
    def handle_click(self, lx, ly):
        if not self.flying:
            self.vx, self.vy, self.flying = (lx-50)*0.05, (ly-250)*0.05, True
    def step(self): return False
    def update(self):
        if self.flying:
            self.x += self.vx; self.y += self.vy; self.vy += 0.3
            if self.y > 480 or self.x > 540 or self.x < 0:
                self.x, self.y, self.vx, self.vy, self.flying = 50, 250, 0, 0, False
    def render(self, surf, ox, oy):
        pygame.draw.circle(surf, (255,200,80), (ox+int(self.x), oy+int(self.y)), 12)
        for tx, ty in [(300, 100),(450, 200),(380, 350)]:
            pygame.draw.circle(surf, (220,80,80), (ox+tx, oy+ty), 16, 2)

class Pattern:
    def __init__(self):
        self.seq = [random.randint(0,3)]; self.user = []; self.last_combo = 0
    def handle_click(self, lx, ly):
        c = lx // 135
        if not (0 <= c < 4): return
        self.user.append(c)
        if len(self.user) == len(self.seq):
            if self.user == self.seq:
                self.seq.append(random.randint(0,3)); self.last_combo = len(self.seq)-1
            else:
                self.seq = [random.randint(0,3)]; self.last_combo = 0
            self.user = []
    def step(self): return False
    def render(self, surf, ox, oy):
        cols = [(220,80,80),(80,200,80),(80,140,220),(220,200,80)]
        for i in range(4):
            pygame.draw.rect(surf, cols[i], (ox+i*135+5, oy+200, 125, 80))
        surf.blit(font.render(f'len(seq)={len(self.seq)} progress={len(self.user)}', True, (255,255,255)), (ox+5, oy+5))

class Logic:
    def __init__(self): self.inp = [False, False, False]; self.last_combo = 0
    def handle_click(self, lx, ly):
        for i in range(3):
            if (lx-80)**2 + (ly-100-i*100)**2 < 400: self.inp[i] = not self.inp[i]
    def step(self): return False
    def render(self, surf, ox, oy):
        for i in range(3):
            col = (80,200,80) if self.inp[i] else (60,60,60)
            pygame.draw.circle(surf, col, (ox+80, oy+100+i*100), 20)
            surf.blit(font.render(['A','B','C'][i], True, (255,255,255)), (ox+72, oy+92+i*100))
        out = self.inp[0] and self.inp[1] and self.inp[2]
        pygame.draw.rect(surf, (60,60,60), (ox+200, oy+170, 100, 60))
        surf.blit(big.render('AND', True, (255,255,255)), (ox+220, oy+185))
        pygame.draw.circle(surf, (80,200,80) if out else (60,60,60), (ox+360, oy+200), 25)

PUZZLES = {'match3': Match3(), 'sliding': Sliding(), 'physics': Physics(), 'pattern': Pattern(), 'logic': Logic()}
NAMES = ['match3', 'sliding', 'physics', 'pattern', 'logic']
active = 'match3'; log = []
running = True
while running:
    for e in pygame.event.get():
        if e.type == pygame.QUIT: running = False
        elif e.type == pygame.KEYDOWN and pygame.K_1 <= e.key <= pygame.K_5:
            active = NAMES[e.key - pygame.K_1]; log.append(f'switch {active}')
        elif e.type == pygame.MOUSEBUTTONDOWN:
            mx, my = e.pos
            if 200 <= mx <= 760 and 60 <= my <= 480:
                PUZZLES[active].handle_click(mx-200, my-60); log.append(f'click on {active}')
    if hasattr(PUZZLES[active], 'update'): PUZZLES[active].update()
    log = log[-8:]
    screen.fill((20,20,30))
    for i, n in enumerate(NAMES):
        col = (80,160,220) if n == active else (60,60,60)
        pygame.draw.rect(screen, col, (10, 60+i*60, 180, 50))
        screen.blit(big.render(f'{i+1}: {n}', True, (255,255,255)), (20, 75+i*60))
    pygame.draw.rect(screen, (10,10,15), (200, 60, 560, 420))
    PUZZLES[active].render(screen, 200, 60)
    pygame.draw.rect(screen, (10,10,15), (770, 60, 308, 420))
    screen.blit(big.render(f'combo: {PUZZLES[active].last_combo}', True, (255,255,255)), (780, 70))
    for i, line in enumerate(log):
        screen.blit(font.render(line, True, (200,200,200)), (780, 120+i*20))
    screen.blit(big.render('Five Genres, One Interface — keys 1-5 switch genre', True, (255,255,255)), (10, 10))
    pygame.display.flip()
    clock.tick(60)
pygame.quit(); sys.exit()

🎯 Quick Quiz

Question 1: The controller dispatches every event with PUZZLES[active].handle_click(...) and PUZZLES[active].render(...) rather than branching if active == 'match3': match3.handle_click(...) elif active == 'sliding': sliding.handle_click(...) .... Why is the dict-dispatch approach the architectural fit at PUZZLE-GENRE scope?

Question 2: Both Match3 and Sliding store their boards as grid[r][c] = int 2D arrays — match-3 with values 0–5 plus −1 for cleared cells on an 8×8 board, sliding with values 0–15 (0 = empty) on a 4×4 board. Why is the 2D-array shape the architectural fit at BOARD-REPRESENTATION scope?

Question 3: After a valid match-3 swap, the code runs combo = 0 then while self.step(): combo += 1, where step() performs {detect runs of 3+ → mark cleared → drop survivors → spawn new from top → return True if any change happened, False if state stabilized}. Why is the fixed-point loop with iteration-count-as-combo-multiplier the architectural fit at MATCH-CASCADE scope?

What's Next?

Now that you've mastered puzzle game logic, next we'll explore racing game physics!