Most of the time, the challenges I tackle with Ruby solve code-related problems. This includes things like web services, scripts, chatbots, etc., all of which are virtual in some way. Recently though, I decided to solve a puzzle in the real world.
My son was trying to put together a wooden puzzle. It requires having four colors (blue, green, red, and yellow) each present on all four sides of a rectangle. These colors are on wooden cubes that can be rotated. So four cubes, each with six sides to rotate around. It turns out that this means there are a lot of possible combinations of these cubes, most of which won’t work. By my math, six sides can face up (a “y
” axis), multiplied by four possible rotations around that axis (6 * 4 = 24
) for each cube. Raised to the power of the four cubes (24^4 = 331776
). After about 30 minutes of hearing his frustration, I told him “I bet I can solve this puzzle with some code” but he was skeptical.
I took that as a challenge to both my coding skills and his respect for me as his dad, so I obliged. After finishing, I figured it would be worth sharing.
Modeling the Physical World
The first thing I needed to do was figure out how to capture this physical puzzle in code. I started with the individual cubes. Initially, I just decided to layout the cube by counting the sides sequentially (starting at 0
, naturally) like this:

Unwrapped, this looks like:

After imagining a few rotations of a cube I realized that it wasn’t a straightforward way to rotate along both axes. I couldn’t just take an array of the colors for each side and call #rotate
on it. Here are what the rotations looked like:

Rotations around either axis only result in four faces rotating, leaving the other two stationary. This messes up any simple rotation scheme. But those two stationary sides got me thinking. There’s something special about opposite sides. I realized that I could treat opposite sides as pairs. If you stare at a cube, you can only see three sides at once. By treating opposite sides as pairs, “flipping” between visible and hidden is always a simple 90 degree rotation away.
Here is what the new cube might look like:

Unwrapped, it is easier to see that opposite sides are “next to each other” when numbered (“0
” is opposite “1
“, “2
” is opposite “3
“, and “4
” is opposite “5
“):

Given this, a switch from “visible” to “hidden” of opposite sides looks like a simple rotation:

Hiding side “2” makes side “3” visible and hiding side “0” shows side “1”, no matter which axis we rotate.
Looking at Some Code
With that in mind, let’s dive into some code. First, I modeled the Cube
:
class Cube
attr_accessor :sides
ALLOWED_COLORS = %i[red green yellow blue].sort.freeze
def initialize(sides)
@sides = sides
end
# Rotations involve some randomness. Take each pair of opposites and flip some of them
def rotate!
new_sides = []
sides.each_slice(2) do |side_pair|
new_sides << (rand(2).odd? ? side_pair : side_pair.rotate)
end
@sides = new_sides.shuffle.flatten
end
end
Then I took on modeling the Puzzle
that held the cubes:
class Puzzle
attr_accessor :attempts, :cubes
def initialize(cubes)
@cubes = cubes
@attempts = 0
end
# The faces of the puzzle consist of only four cube sides
def faces
[0, 1, 4, 5].map { |n| cubes.map { |c| c.sides[n] } }
end
# Solving the puzzle involves rotating cubes until a solution is found
def solve
cubes.sample.rotate! until solved?
self
end
# Solving means all four sides look like the list of allowed colors
def solved?
@attempts += 1
faces.each { |face| return false unless face.sort == Cube::ALLOWED_COLORS }
true
end
end
From there, it was a simple matter of constructing the four cubes (listing each side in the proper order) and then solving:
cubes = [
Cube.new(%i[yellow blue blue red green green]),
Cube.new(%i[yellow yellow yellow green red blue]),
Cube.new(%i[red green blue yellow red yellow]),
Cube.new(%i[yellow green red green blue red])
]
puzzle = Puzzle.new(cubes)
puzzle.solve
puts "Total attempts: #{puzzle.attempts}"
puts "["
puzzle.faces.each { |face| puts " [#{face.join(", ") }]" }
puts "]"
That’s it! The output ends up looking something like this:
Total attempts: 12163
[
[red, yellow, blue, green]
[blue, green, yellow, red]
[yellow, blue, red, green]
[blue, red, green, yellow]
]
Check out the entire solution in this gist. Let me know what you think in the comments!