Nate Dalo

October 24, 2025

Advent of Code, Ruby, and Object-Oriented Design

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?

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

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

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

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

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:
  1. Find the part of the code that changes
  2. Name the concept
  3. Extract it into its own class
  4. 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.