---
title: "I Built a Rubik’s Cube Solver in One HTML File with Claude (Three.js, a Layer-by-Layer Solver, and Photo Scanning)"
seoTitle: "I Built a Rubik's Cube Solver in One HTML File with Claude (Three.js, a Layer-by-Layer Solver, and Photo Scanning)"
description: "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."
date: "2026-09-01"
type: "build"
reel: "DcvCfoRSJ_4"
tags: []
ogTitle: "I Built a Rubik's Cube Solver in One HTML File with Claude (Three.js, a Layer-by-Layer Solver, and Photo Scanning)"
legacy: true
---
<h2>Overview</h2>
      <p>
        It is called Cube Lab, and it is one <code>.html</code> file. Open it and
        there is a 3&times;3 sitting in a dark studio. Drag a face and that layer
        turns. Drag the background and the camera orbits. Press
        <code>U D L R F B</code> to turn, hold shift to reverse. Hit
        <strong>Scramble</strong>, then hit <strong>Solve it</strong>, 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.
      </p>
      <p>
        And it will solve <em>your</em> cube. There is a scanner that takes six
        photos &mdash; or a webcam feed &mdash; reads the 54 stickers, and loads
        that state onto the virtual cube.
      </p>
      <p>
        No build step, no npm, no solver library. The only external dependency is
        Three.js r128 from a CDN. Everything else &mdash; the group theory, the
        search, the colour classifier &mdash; is in the file.
      </p>

      <div class="highlight">
        <p>
          <strong>What it does:</strong> 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.
        </p>
        <p style="margin-bottom: 0">
          <strong>Stack:</strong> 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.
        </p>
      </div>

      <h2>The cube is not a 3D model. It is four arrays.</h2>
      <p>
        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 &mdash; the standard corner/edge representation:
      </p>
      <div class="code">{
  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
}</div>
      <p>
        A move is just another state, and applying a move is composing two
        permutations:
      </p>
      <div class="code">// 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
}</div>
      <p>
        Define the six quarter turns by hand, generate the other twelve
        (<code>U2</code>, <code>U'</code>, &hellip;) by repeated multiplication,
        and you have the whole move set. Solved is the identity permutation.
        Everything downstream &mdash; scrambling, searching, validating,
        cancelling moves &mdash; is arithmetic on integers, which is why a
        brute-force search inside a browser tab finishes instantly.
      </p>
      <p>
        The 3D scene is a projection of that. 54 sticker meshes get repainted
        from the state via <code>toFacelets()</code>, and a turn animation
        rotates a temporary group of nine cubies, then snaps their quaternions to
        the nearest 90&deg; and re-parents them. The visual cube never drifts,
        because it is re-derived rather than accumulated.
      </p>

      <h2>Solving it: seven named stages, not one big search</h2>
      <p>
        God&rsquo;s number is 20, but a 20-move optimal solution is useless to a
        human and expensive to compute. Kociemba&rsquo;s two-phase algorithm
        needs multi-megabyte pruning tables. Neither is what you want in a file
        you can email to someone.
      </p>
      <p>
        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:
      </p>
      <ol>
        <li><strong>White cross</strong></li>
        <li><strong>White corners</strong></li>
        <li><strong>Middle layer</strong></li>
        <li><strong>Yellow cross</strong></li>
        <li><strong>Yellow face</strong> (orient the last-layer corners)</li>
        <li><strong>Corner positions</strong></li>
        <li><strong>Edge positions</strong></li>
      </ol>
      <p>
        The interesting part is that the stages use two different search
        strategies depending on how constrained the problem is.
      </p>

      <h3>Early stages: constrained iterative deepening</h3>
      <p>
        For the cross and the first two layers, the goal is partial &mdash;
        &ldquo;these edge slots and these corner slots are home and untwisted,
        ignore everything else&rdquo;:
      </p>
      <div class="code">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;
  };
}</div>
      <p>
        Then a plain IDDFS over all 18 moves, depth 1 upward, with one pruning
        rule &mdash; 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.
      </p>

      <h3>Late stages: search over algorithms, not over moves</h3>
      <p>
        The last layer is where blind search dies &mdash; 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
        <em>conjugated algorithms</em>:
      </p>
      <div class="code">// 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])));
}</div>
      <p>
        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&ndash;12 generators is trivial, and &mdash; this is
        the point &mdash; the output is the same sequence a human speedcuber
        would execute. The solution is teachable, not just correct.
      </p>

      <h2>The move optimiser</h2>
      <p>
        Stitching seven independent stages together produces garbage at the
        seams: <code>R</code> followed immediately by <code>R'</code>, or
        <code>U</code> and <code>U</code> where <code>U2</code> belongs. So there
        is a <code>tidy()</code> pass that repeatedly:
      </p>
      <ul>
        <li>
          merges consecutive turns of the same face
          (<code>R + R2 = R'</code>, <code>R + R' =</code> nothing), and
        </li>
        <li>
          slides a move back past a move on the <em>opposite</em> face &mdash;
          <code>U</code> and <code>D</code> commute, so <code>R D R'</code> can
          collapse even though the moves are not adjacent.
        </li>
      </ul>
      <p>
        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&rsquo;s section headings still line up with the right
        slice of the final list. That bookkeeping is what most naive
        implementations get wrong &mdash; the optimiser works, and then the UI
        highlights the wrong step.
      </p>

      <h2>Reading a real cube from photos</h2>
      <p>
        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.
      </p>
      <p>
        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.
      </p>
      <p>Three things fix it.</p>
      <p>
        <strong>1. Chromaticity, not colour.</strong> Each patch becomes a
        four-dimensional feature of normalised channels plus saturation, so
        overall brightness drops out:
      </p>
      <div class="code">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 ];
}</div>
      <p>
        <strong>2. The user&rsquo;s own centres are the reference.</strong> A
        centre sticker can never move, so the middle patch of each scanned face
        <em>is</em> 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.
      </p>
      <p>
        <strong>3. A counting constraint.</strong> 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 &mdash; 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.
      </p>

      <h2>Rejecting impossible cubes</h2>
      <p>
        If the scan is still wrong, the app must say so &mdash; a solver fed an
        invalid state will search forever or emit nonsense. Three invariants
        catch essentially every misread, and they are pure group theory:
      </p>
      <ul>
        <li>
          <strong>Corner twist sums to 0 mod 3.</strong> Otherwise one corner is
          twisted in place.
        </li>
        <li>
          <strong>Edge flip sums to 0 mod 2.</strong> Otherwise one edge is
          flipped in place.
        </li>
        <li>
          <strong>Corner and edge permutation parity match.</strong> Otherwise
          two pieces have been swapped.
        </li>
      </ul>
      <div class="code">if (permParity(state.cp) !== permParity(state.ep))
  return 'Two pieces are swapped — this cube cannot be solved.';</div>
      <p>
        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 &mdash; &ldquo;a corner is
        twisted on its own, check the corner stickers.&rdquo; Half the time the
        cube really has been taken apart and reassembled wrong, and the app tells
        you that.
      </p>

      <h2>What Claude was actually good at</h2>
      <p>
        Two things stood out while building this.
      </p>
      <p>
        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.
      </p>
      <p>
        The second is that the good decisions were the ones about
        <em>constraints</em>, 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 &mdash; they are each a few &mdash; 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.
      </p>
      <p>
        The part that still needed me: deciding that the solution should be
        <em>readable</em>. 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.
      </p>

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