Advent of Code is a yearly programming challenge that’s a great way to keep your skills sharp. You can solve problems from past years at any time, which makes it a perfect ongoing exercise. To get the most out of it, I like to turn off anything in my editor that might give me hints or shortcuts. That means no Copilot, and even no syntax helpers like ALE in Vim (:Copilot disable, :ALEDisable).
One way to make the challenge even harder is to not just solve the challenge, but also practice writing clean, object-oriented code that can adapt to changing requirements. This is what we'll be exploring in this blog.
Part 1:
I’m going to walk through a Ruby solution for Advent of Code 2022, Day 2, using object-oriented design principles. It’s an approachable example: it's easy to understand and part 2 adds a new requirement that is also easy to understand. With a small bit of refactoring, we’ll end up with a solution that follows the SOLID principles.
Here’s the problem: https://adventofcode.com/2022/day/2
Try solving Part 1 and Part 2 on your own first—how would you handle the new requirements?
Try solving Part 1 and Part 2 on your own first—how would you handle the new requirements?
The sample input looks like this:
A Y B X C Z
In Part 1, we’re modeling a Rock, Paper, Scissors game. The first character in each line represents what the opponent picks, and the second represents what we pick:
• A = Rock, B = Paper, C = Scissors
• X = Rock, Y = Paper, Z = Scissors
• X = Rock, Y = Paper, Z = Scissors
Scoring works like this:
• Lose: 0 points
• Draw: 3 points
• Win: 6 points
• Plus extra points for your choice - Rock = 1, Paper = 2, Scissors = 3
• Draw: 3 points
• Win: 6 points
• Plus extra points for your choice - Rock = 1, Paper = 2, Scissors = 3
For example:
• A Y → Opponent picks Rock, we pick Paper → Win (6) + Paper (2) = 8 points
• B X → Opponent picks Paper, we pick Rock → Lose (0) + Rock (1) = 1 point
• C Z → Opponent picks Scissors, we pick Scissors → Draw (3) + Scissors (3) = 6 points
• B X → Opponent picks Paper, we pick Rock → Lose (0) + Rock (1) = 1 point
• C Z → Opponent picks Scissors, we pick Scissors → Draw (3) + Scissors (3) = 6 points
Total = 8 + 1 + 6 = 15 points
A Simple Ruby Solution
Let’s start with a straightforward Ruby implementation for Part 1. Here’s a neat Ruby trick to load sample input using the DATA constant (see https://www.honeybadger.io/blog/data-and-end-in-ruby/):
data = DATA.readlines(chomp: true) data = data.map { _1.scan(/\w/) } # feel free to add a breakpoint here to inspect data, which now looks like: # [["A", "Y"], ["B", "X"], ["C", "Z"]] __END__ A Y B X C Z
Now we’ll create a class to represent a single Rock, Paper, Scissors game:
class RockPaperScissors attr_reader :opponent, :player TRANSLATE = { "A" => :rock, "B" => :paper, "C" => :scissors, "X" => :rock, "Y" => :paper, "Z" => :scissors }.freeze POINTS = { rock: 1, paper: 2, scissors: 3 } def initialize(opponent, player) @opponent = TRANSLATE[opponent] @player = TRANSLATE[player] end def score base = case game_result when :win then 6 when :draw then 3 else 0 end base + POINTS[player] end private def game_result return :draw if player == opponent wins = { rock: :scissors, paper: :rock, scissors: :paper } wins[player] == opponent ? :win : :lose end end
Now we can run:
game = RockPaperScissors.new("A", "Y").score
To compute the total score:
result = data.map { |g| RockPaperScissors.new(g.first, g.last).score } puts result.sum
This is the type of code that I'd be comfortable putting up for review as long as it was tested. It's small, it's straightforward, and it's easy to understand.
Part 2: Changing Requirements
Part 2 changes the meaning of the second column. Instead of representing what we pick, X/Y/Z now represent the desired outcome:
X = Lose, Y = Draw, Z = Win
The opponent’s values (A/B/C) still represent Rock, Paper, and Scissors.
Example:
• A Y → Opponent plays Rock, we want a Draw → we play Rock → 3 (draw) + 1 (rock) = 4 points
• B X → Opponent plays Paper, we want to Lose → we play Rock → 0 (lose) + 1 (rock) = 1 point
• C Z → Opponent plays Scissors, we want to Win → we play Rock → 6 (win) + 1 (rock) = 7 points
• B X → Opponent plays Paper, we want to Lose → we play Rock → 0 (lose) + 1 (rock) = 1 point
• C Z → Opponent plays Scissors, we want to Win → we play Rock → 6 (win) + 1 (rock) = 7 points
Total = 12 points
At this point, our translation logic needs to change. We can also refactor the logic so that it translates both the opponent's value and the player's value at once, since this is what we'll need in part 2. To make this easy, let’s refactor the current translation logic into its own class. That way, we can swap out the logic for Part 2 without touching the rest of the code.
Step 1: Extract a Translator
require "ostruct" class DefaultTranslator attr_reader :opponent, :player TRANSLATE = { "A" => :rock, "B" => :paper, "C" => :scissors, "X" => :rock, "Y" => :paper, "Z" => :scissors }.freeze def initialize(opponent, player) @opponent = opponent @player = player end def translate OpenStruct.new( opponent: TRANSLATE[opponent], player: TRANSLATE[player] ) end end
Now we update the main class to use this translator. We just inject this dependency into the constructor and use it:
class RockPaperScissors attr_reader :opponent, :player POINTS = { rock: 1, paper: 2, scissors: 3 } def initialize(opponent, player, translator: DefaultTranslator) t = translator.new(opponent, player).translate @opponent = t.opponent @player = t.player end def score base = case game_result when :win then 6 when :draw then 3 else 0 end base + POINTS[player] end private def game_result return :draw if player == opponent wins = { rock: :scissors, paper: :rock, scissors: :paper } wins[player] == opponent ? :win : :lose end end
Step 2: Add a New Translator for Part 2
require "ostruct" class PartTwoTranslator attr_reader :opponent, :player OPPONENT = { "A" => :rock, "B" => :paper, "C" => :scissors }.freeze PLAYER = { "X" => :lose, "Y" => :draw, "Z" => :win }.freeze def initialize(opponent, player) @opponent = opponent @player = player end def translate translated_opponent = OPPONENT.fetch(opponent) OpenStruct.new( opponent: translated_opponent, player: translated_player(translated_opponent) ) end private # this is perhaps a bit verbose, but there are only 9 cases def translated_player(translated_opponent) expected_outcome = PLAYER.fetch(player) { [:draw, :rock] => :rock, [:draw, :paper] => :paper, [:draw, :scissors] => :scissors, [:lose, :paper] => :rock, [:lose, :scissors] => :paper, [:lose, :rock] => :scissors, [:win, :scissors] => :rock, [:win, :rock] => :paper, [:win, :paper] => :scissors }[[expected_outcome, translated_opponent]] end end
Now we can easily switch between translators:
# Part 1 result = data.map { |g| RockPaperScissors.new(g.first, g.last).score } puts result.sum # Part 2 result = data.map { |g| RockPaperScissors.new(g.first, g.last, translator: PartTwoTranslator).score } puts result.sum
Recap
We started with a simple, clear Ruby implementation. When a new requirement came along, instead of piling on conditionals or writing a whole new solution, we extracted the part that changes into its own class and injected it into the main one. This keeps our code more open/closed (the O in SOLID)—it is open for extension—we can swap out the translation logic (and even scoring logic if we want), and doing so doesn't require modifying the existing code. As long as we inject a new class that plays the role of a translator, we can alter the behavior without changing the class.
In short:
- Find the part of the code that changes
- Name the concept
- Extract it into its own class
- Inject it as a dependency
Full Solution
require "ostruct" data = DATA.readlines(chomp: true) data = data.map { _1.scan(/\w/) } class DefaultTranslator attr_reader :opponent, :player TRANSLATE = { "A" => :rock, "B" => :paper, "C" => :scissors, "X" => :rock, "Y" => :paper, "Z" => :scissors }.freeze def initialize(opponent, player) @opponent = opponent @player = player end def translate OpenStruct.new( opponent: TRANSLATE[opponent], player: TRANSLATE[player] ) end end class PartTwoTranslator attr_reader :opponent, :player OPPONENT = { "A" => :rock, "B" => :paper, "C" => :scissors }.freeze PLAYER = { "X" => :lose, "Y" => :draw, "Z" => :win }.freeze def initialize(opponent, player) @opponent = opponent @player = player end def translate translated_opponent = OPPONENT.fetch(opponent) OpenStruct.new( opponent: translated_opponent, player: translated_player(translated_opponent) ) end private def translated_player(translated_opponent) expected_outcome = PLAYER.fetch(player) { [:draw, :rock] => :rock, [:draw, :paper] => :paper, [:draw, :scissors] => :scissors, [:lose, :paper] => :rock, [:lose, :scissors] => :paper, [:lose, :rock] => :scissors, [:win, :scissors] => :rock, [:win, :rock] => :paper, [:win, :paper] => :scissors }[[expected_outcome, translated_opponent]] end end class RockPaperScissors attr_reader :opponent, :player POINTS = { rock: 1, paper: 2, scissors: 3 } def initialize(opponent, player, translator: DefaultTranslator) t = translator.new(opponent, player).translate @opponent = t.opponent @player = t.player end def score base = case game_result when :win then 6 when :draw then 3 else 0 end base + POINTS[player] end private def game_result return :draw if player == opponent wins = { rock: :scissors, paper: :rock, scissors: :paper } wins[player] == opponent ? :win : :lose end end # Part 1 result = data.map { |g| RockPaperScissors.new(g.first, g.last).score } puts result.sum # Part 2 result = data.map { |g| RockPaperScissors.new(g.first, g.last, translator: PartTwoTranslator).score } puts result.sum __END__ A Y B X C Z
Advent of Code isn’t just a fun challenge—it’s a great opportunity to practice thoughtful design. The more you can write adaptable, modular code under simple conditions, the better prepared you’ll be when real-world requirements start changing on you.