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
We focus on three main improvements: decoupling data from logic, implementing the

Prerequisites
To follow along with this tutorial, you should have a solid grasp of 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
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
DieandHandclasses.
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
__str__and__repr__: Instead of creating ashow_hand()method that callsprint(), 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
@abstractmethoddecorator 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
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 thesidesvariable 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
handobject into the rule'spoints()method. This makes the rules "pure functions," which are significantly easier to debug than methods that rely on external, global variables.

Fancy watching it?
Watch the full video and context