Most of the time, 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, there are six sides that 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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:
1 2 3 4 5 6 7 |
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!