Mastering Python Refactoring: Transforming a Yahtzee Game with Design Principles

Overview

Code refactoring is the process of restructuring existing computer code without changing its external behavior. It is a critical skill for any developer looking to move beyond writing functional scripts to building maintainable, professional software. In this guide, we take a

game implementation and break it apart to address common architectural smells. By applying
Object-Oriented Programming
principles, we can transform a tightly coupled, monolithic script into a modular system that is easy to extend and test.

We focus on three main improvements: decoupling data from logic, implementing the

for game rules, and adhering to the
Model-View-Controller
(MVC) pattern. These changes don't just fix bugs; they make the code resilient to change. Whether you want to add a "Fibonacci" scoring rule or port the game from a console to a mobile app, a well-refactored architecture makes those transitions seamless.

Mastering Python Refactoring: Transforming a Yahtzee Game with Design Principles
CODE ROAST: Yahtzee - New Python Code Refactoring Series!

Prerequisites

To follow along with this tutorial, you should have a solid grasp of

fundamentals, including classes, inheritance, and list comprehensions. Familiarity with
Unit Testing
using unittest or pytest is highly recommended, as we use tests to verify that our refactoring doesn't break existing game logic. You should also understand basic
SOLID
principles, particularly the Open-Closed Principle.

Key Libraries & Tools

  • Python
    (3.x)
    : The primary language used for the implementation.
  • random: A built-in module used to simulate dice rolls.
  • abc (Abstract Base Classes): Used to define formal interfaces for our game rules.
  • unittest
    : The testing framework used to validate the behavior of individual components like the Die and Hand classes.

Code Walkthrough: Decoupling the Core Logic

Refining the Die and Hand Classes

The original code suffered from "leaky abstractions" where the console printing was mixed with the dice logic. Our first step is to isolate the Die and Hand classes so they only handle data and behavior.

import random

class Die:
    def __init__(self, sides=6, face=None):
        self.sides = sides
        self.face = face if face else self.roll()

    def roll(self):
        self.face = random.randint(1, self.sides)
        return self.face

    def __str__(self):
        return str(self.face)

In this updated Die class, we solved a bug where the original always rolled a 6-sided die regardless of the sides attribute. By adding a face parameter to the initializer, we also made the class much easier to test. If you want to test a "Full House," you can now instantiate dice with specific values rather than waiting for a random roll.

Implementing Rule Strategies

One of the biggest issues in the original design was the Rules class. It was a "God Object" containing every possible scoring calculation. This violates the Open-Closed Principle because adding a new rule requires modifying the class. We solve this by using the Strategy Pattern with an abstract base class.

from abc import ABC, abstractmethod

class Rule(ABC):
    @property
    @abstractmethod
    def name(self):
        pass

    @abstractmethod
    def points(self, hand):
        pass

By defining this interface, every rule becomes its own self-contained class. If we want to create a rule for "Aces" (counting all ones), we simply subclass Rule. This allows us to group logic and metadata (like the name of the rule) together, preventing the Scoreboard from having to maintain a separate, fragile list of rule names.

The Scoreboard and Game Controller

The Scoreboard should not know how to play the game; it should only know how to record points. Similarly, the YahtzeeGame class acts as our controller, managing the flow between the user's input (the View) and the game data (the Model).

class Scoreboard:
    def __init__(self):
        self.rules = []
        self.points = []

    def register_rules(self, rules):
        self.rules.extend(rules)
        self.points = [0] * len(self.rules)

    def assign_points(self, rule, hand):
        # Logic to find the rule index and update points
        pass

Syntax Notes

We utilized several

idioms to keep the code clean:

  • __str__ and __repr__: Instead of creating a show_hand() method that calls print(), we implement __str__. This allows the controller to decide when and where to display the string representation of the hand.
  • Underscore in Loops: When we need to repeat an action a specific number of times but don't need the index variable, we use for _ in range(n). This signals to other developers that the variable is intentionally unused.
  • Abstract Base Classes (ABC): Using the @abstractmethod decorator ensures that any developer adding a new rule must implement the required methods, providing a form of compile-time safety in a dynamic language.

Practical Examples

The power of this refactored architecture is most apparent when you want to add custom features. During the session, we implemented a Fibonacci Rule. In the old system, this would have required editing three different files and ensuring indices matched. In the new system, we simply created a FibonacciRule class and registered it in the YahtzeeGame initializer.

Another real-world application is cross-platform development. Because the Hand and Scoreboard classes no longer contain print() or input() calls, you could import these exact same files into a

web app or a
PyQt
desktop interface without changing a single line of logic.

Tips & Gotchas

  • Don't Test Trivialities: One common mistake is writing tests that only check if an instance variable was set (e.g., self.sides = 6). Focus on testing behavior. Instead of checking if the sides variable is 6, roll the die 1,000 times and ensure no value exceeds 6.
  • The "Happy Path" Trap: It is easy to write tests for when things go right. Always include tests for the "not-so-happy" paths. If a player tries to score a "Full House" with a hand of [1, 2, 3, 4, 5], your code should explicitly return 0 points rather than crashing or returning an undefined value.
  • Avoid Global State: Notice that we pass the hand object into the rule's points() method. This makes the rules "pure functions," which are significantly easier to debug than methods that rely on external, global variables.
Mastering Python Refactoring: Transforming a Yahtzee Game with Design Principles

Fancy watching it?

Watch the full video and context

6 min read