← Learn · Build · 1 September 2026 · 8 min

I Built a Rubik’s Cube Solver in One HTML File with Claude (Three.js, a Layer-by-Layer Solver, and Photo Scanning)

Cube Lab is a single HTML file: a Three.js 3x3 you can drag-turn, a layer-by-layer solver written from scratch with IDDFS and conjugated generators, and a scanner that reads your real cube from six photos or your webcam.

Overview

It is called Cube Lab, and it is one .html file. Open it and there is a 3×3 sitting in a dark studio. Drag a face and that layer turns. Drag the background and the camera orbits. Press U D L R F B to turn, hold shift to reverse. Hit Scramble, then hit Solve it, and a panel slides out with the full solution broken into named stages you can step through one move at a time, scrub with a progress bar, or just play.

And it will solve your cube. There is a scanner that takes six photos — or a webcam feed — reads the 54 stickers, and loads that state onto the virtual cube.

No build step, no npm, no solver library. The only external dependency is Three.js r128 from a CDN. Everything else — the group theory, the search, the colour classifier — is in the file.

What it does: a dark-and-light-themed Three.js cube with drag-to-turn faces, keyboard notation, a speed slider, a seven-stage layer-by-layer solver with a move-cancellation pass, a step/play/scrub solution player, and a scanner that reads a physical cube from photos or the camera and validates it.

Stack: one HTML file. Three.js r128 for rendering, a hand-rolled permutation-and-orientation cube model, iterative-deepening DFS, and a 2D canvas for the scanner. Roughly 2,000 lines.

The cube is not a 3D model. It is four arrays.

The single decision that makes everything else tractable: the cube state the solver reasons about has nothing to do with the meshes on screen. A state is four arrays — the standard corner/edge representation:

{ cp: [0..7], // corner permutation: which corner piece sits in each slot co: [0..7], // corner orientation: 0, 1 or 2 twists ep: [0..11], // edge permutation eo: [0..11] // edge orientation: 0 or 1 flips }

A move is just another state, and applying a move is composing two permutations:

// result = a then b function multiply(a, b) { // for each slot, look up which piece b pulls in, // then which piece a had there, and add the twists mod 3 / flips mod 2 }

Define the six quarter turns by hand, generate the other twelve (U2, U', …) by repeated multiplication, and you have the whole move set. Solved is the identity permutation. Everything downstream — scrambling, searching, validating, cancelling moves — is arithmetic on integers, which is why a brute-force search inside a browser tab finishes instantly.

The 3D scene is a projection of that. 54 sticker meshes get repainted from the state via toFacelets(), and a turn animation rotates a temporary group of nine cubies, then snaps their quaternions to the nearest 90° and re-parents them. The visual cube never drifts, because it is re-derived rather than accumulated.

God’s number is 20, but a 20-move optimal solution is useless to a human and expensive to compute. Kociemba’s two-phase algorithm needs multi-megabyte pruning tables. Neither is what you want in a file you can email to someone.

So the solver is the layer-by-layer method a person actually learns, implemented as seven stages, each of which appends to one move list and records a snapshot:

  1. White cross
  2. White corners
  3. Middle layer
  4. Yellow cross
  5. Yellow face (orient the last-layer corners)
  6. Corner positions
  7. Edge positions

The interesting part is that the stages use two different search strategies depending on how constrained the problem is.

Early stages: constrained iterative deepening

For the cross and the first two layers, the goal is partial — “these edge slots and these corner slots are home and untwisted, ignore everything else”:

function makeGoal(edgeSlots, cornerSlots) { return function (s) { for (var e of edgeSlots) if (s.ep[e] !== e || s.eo[e] !== 0) return false; for (var c of cornerSlots) if (s.cp[c] !== c || s.co[c] !== 0) return false; return true; }; }

Then a plain IDDFS over all 18 moves, depth 1 upward, with one pruning rule — never turn the same face twice in a row. Each cross edge solves in six moves or fewer, so the search space stays small, and because previously solved slots are included in the goal, the search physically cannot wreck them.

Late stages: search over algorithms, not over moves

The last layer is where blind search dies — the sequences are 7 to 14 moves long and a raw IDDFS would never surface. So the generators change. Instead of searching over single turns, it searches over conjugated algorithms:

// U^a . alg . U^-a for a = 0..3 function conjugates(algs) { for (a of [0,1,2,3]) for (alg of algs) out.push(U_TURNS[a].concat(alg).concat(invertSequence(U_TURNS[a]))); }

Feed it Sune and Antisune and you get 8 generators that orient the last layer; feed it the A-perms and the T-perm and you get the corner permutation stage; the U-perm and its inverse handle the edges. A depth-3 search over 8–12 generators is trivial, and — this is the point — the output is the same sequence a human speedcuber would execute. The solution is teachable, not just correct.

The move optimiser

Stitching seven independent stages together produces garbage at the seams: R followed immediately by R', or U and U where U2 belongs. So there is a tidy() pass that repeatedly:

  • merges consecutive turns of the same face (R + R2 = R', R + R' = nothing), and
  • slides a move back past a move on the opposite face — U and D commute, so R D R' can collapse even though the moves are not adjacent.

It loops until nothing changes. The subtlety is that every move carries a stage tag through the whole process, so when moves cancel across a stage boundary the panel’s section headings still line up with the right slice of the final list. That bookkeeping is what most naive implementations get wrong — the optimiser works, and then the UI highlights the wrong step.

Reading a real cube from photos

The scanner was the hardest part, and not for the reason I expected. Detecting the grid is easy: you show the user a square guide, they line the face up, and you average a small patch at the centre of each of the nine cells. Classifying the colours is where it falls apart.

Red and orange are close. Under warm indoor light, white goes yellow and yellow goes orange. Absolute RGB thresholds fail on any cube that is not photographed in a lightbox.

Three things fix it.

1. Chromaticity, not colour. Each patch becomes a four-dimensional feature of normalised channels plus saturation, so overall brightness drops out:

function feat(rgb) { var sum = r + g + b || 1; var sat = mx > 0 ? (mx - mn) / mx : 0; return [ r/sum*2.4, g/sum*2.4, b/sum*2.4, sat*1.5 ]; }

2. The user’s own centres are the reference. A centre sticker can never move, so the middle patch of each scanned face is the ground truth for that colour, in that lighting, on that cube, from that camera. Every other sticker is classified by nearest neighbour against those six references. There is no hardcoded palette in the final pass at all.

3. A counting constraint. Nearest-neighbour alone will happily return ten reds and eight oranges. But a real cube has exactly nine of each colour, and the centres already claim one each — so the classifier gives every colour a budget of eight, assigns stickers in order of confidence (the margin between their best and second-best match), and then runs pairwise swap passes: if swapping the labels on two stickers lowers the total cost, swap them. It is a cheap approximation to an optimal assignment, and it turns the ambiguous red/orange pairs into the arrangement that is globally cheapest rather than locally greedy.

Rejecting impossible cubes

If the scan is still wrong, the app must say so — a solver fed an invalid state will search forever or emit nonsense. Three invariants catch essentially every misread, and they are pure group theory:

  • Corner twist sums to 0 mod 3. Otherwise one corner is twisted in place.
  • Edge flip sums to 0 mod 2. Otherwise one edge is flipped in place.
  • Corner and edge permutation parity match. Otherwise two pieces have been swapped.
if (permParity(state.cp) !== permParity(state.ep)) return 'Two pieces are swapped — this cube cannot be solved.';

Plus the cheap checks: six distinct centres, exactly nine of each colour, no duplicate pieces. Every one of those returns a sentence written for a person holding a cube, not a stack trace — “a corner is twisted on its own, check the corner stickers.” Half the time the cube really has been taken apart and reassembled wrong, and the app tells you that.

What Claude was actually good at

Two things stood out while building this.

The first is that the boring, exacting parts were the fast parts. Writing out six base moves as explicit permutation arrays, or the facelet-index mapping that assigns each of 54 stickers a position and outward normal in 3D space, is the sort of work that takes a human an afternoon and one transposed index to lose the evening to. Getting that right on the first pass is where the leverage was.

The second is that the good decisions were the ones about constraints, not code. Searching over conjugated algorithms instead of over moves. Using the centres as colour references instead of a palette. Enforcing nine-of-each as an assignment problem. None of those are more lines — they are each a few — and each one removes an entire class of failure. The gap between a cube solver that works on a clean scramble and one that works on a phone photo of a cube on a desk is almost entirely those three ideas.

The part that still needed me: deciding that the solution should be readable. An optimal 20-move solution would have been a more impressive number and a worse product. Seven named stages that map onto the method people actually learn, with a step-through player, is the thing someone can hold their cube against.

Recap

  • Model the cube as permutation + orientation arrays. The 3D scene renders from that state; it never becomes the source of truth.
  • Layer-by-layer beats optimal for anything a human reads. Seven staged searches, each with a partial goal that protects what is already solved.
  • Change your generators to match the depth. Single moves for the first two layers; conjugated algorithms for the last one.
  • Cancel moves at the seams afterwards, and carry stage tags through the cancellation so the UI stays honest.
  • For colour scanning: normalise out brightness, use the cube’s own centres as references, and constrain to nine of each colour.
  • Validate with the three cube invariants before you search, and phrase the failures for someone holding a cube.