3D Rubik's Cube — Complete Technical Build Prompt

Goal: Build a fully interactive, photorealistic 3D Rubik's Cube that runs entirely in a single HTML file in the browser with zero installation. The cube sits in a dark, moody, spotlight-lit environment. Users can drag faces to twist layers, drag elsewhere to orbit the camera, and use a frosted-glass floating control panel for moves, scramble, undo, reset, and speed control. Keyboard shortcuts are supported. Everything animates smoothly.


1. Tech Stack & Dependencies

This project is a single index.html file with all CSS and JS inline. No build tools, no npm, no bundler.

Libraries (loaded from CDN)

Library Version CDN URL Purpose
Three.js r162 https://cdnjs.cloudflare.com/ajax/libs/three.js/r162/three.min.js 3D rendering engine
OrbitControls r162 https://cdn.jsdelivr.net/npm/three@0.162.0/examples/js/controls/OrbitControls.js Camera orbiting (we will use this as a fallback/reference but implement custom drag logic)

Important: We do NOT use React, Vue, or any framework. Pure vanilla JS + Three.js only. We do NOT use ES module imports (import { Scene } from 'three') because OrbitControls via the non-module CDN path requires the global THREE namespace. Use the classic script tag approach.

Browser Requirements


2. File Structure

One single file:

index.html    ← Contains ALL HTML, CSS (in <style>), and JS (in <script>)

Nothing else. No separate .css or .js files. This must be fully self-contained and openable by double-clicking the HTML file.


3. HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Rubik's Cube</title>
  <!-- Three.js from CDN -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r162/three.min.js"></script>
  <style>
    /* ALL CSS GOES HERE — described in Section 4 */
  </style>
</head>
<body>

  <!-- 3D Canvas Container -->
  <div id="canvas-container"></div>

  <!-- Floating Control Panel -->
  <div id="control-panel">

    <!-- Row 1: Title -->
    <div id="panel-title">RUBIK'S CUBE</div>

    <!-- Row 2: Face Move Buttons -->
    <div id="move-buttons" class="button-row">
      <button class="move-btn" data-move="R" title="R move (Right face clockwise)">R</button>
      <button class="move-btn" data-move="R'" title="R' move (Right face counter-clockwise)">R'</button>
      <button class="move-btn" data-move="L" title="L move (Left face clockwise)">L</button>
      <button class="move-btn" data-move="L'" title="L' move">L'</button>
      <button class="move-btn" data-move="U" title="U move (Up face clockwise)">U</button>
      <button class="move-btn" data-move="U'" title="U' move">U'</button>
      <button class="move-btn" data-move="D" title="D move (Down face clockwise)">D</button>
      <button class="move-btn" data-move="D'" title="D' move">D'</button>
      <button class="move-btn" data-move="F" title="F move (Front face clockwise)">F</button>
      <button class="move-btn" data-move="F'" title="F' move">F'</button>
      <button class="move-btn" data-move="B" title="B move (Back face clockwise)">B</button>
      <button class="move-btn" data-move="B'" title="B' move">B'</button>
    </div>

    <!-- Row 3: Middle slice moves -->
    <div id="slice-buttons" class="button-row">
      <button class="move-btn slice-btn" data-move="M" title="M move (Middle slice)">M</button>
      <button class="move-btn slice-btn" data-move="M'" title="M' move">M'</button>
      <button class="move-btn slice-btn" data-move="E" title="E move (Equator slice)">E</button>
      <button class="move-btn slice-btn" data-move="E'" title="E' move">E'</button>
      <button class="move-btn slice-btn" data-move="S" title="S move (Standing slice)">S</button>
      <button class="move-btn slice-btn" data-move="S'" title="S' move">S'</button>
    </div>

    <!-- Row 4: Action Buttons + Speed Slider -->
    <div id="action-row" class="button-row">
      <button id="btn-scramble" class="action-btn" title="Scramble cube (Space)">⟳ Scramble</button>
      <button id="btn-undo" class="action-btn" title="Undo last move (Ctrl+Z)">↩ Undo</button>
      <button id="btn-reset" class="action-btn" title="Reset to solved (Escape)">✕ Reset</button>
      <div id="speed-control">
        <label for="speed-slider">Speed</label>
        <input type="range" id="speed-slider" min="1" max="10" value="5" step="1" />
      </div>
    </div>

    <!-- Row 5: Keyboard shortcut hint -->
    <div id="shortcut-hint">
      Keys: R L U D F B (hold Shift for prime) · Space=Scramble · Ctrl+Z=Undo · Esc=Reset
    </div>

  </div>

  <script>
    /* ALL JAVASCRIPT GOES HERE — described in Section 6 */
  </script>

</body>
</html>

Element Reference Table

Element ID / Class Purpose
#canvas-container Full-screen div Three.js renderer mounts here
#control-panel Floating top panel Frosted glass UI overlay
#panel-title Text heading "RUBIK'S CUBE" label
.move-btn Class on all move buttons Standard Rubik's notation moves
.slice-btn Additional class Middle/Equator/Standing slice moves
#btn-scramble Scramble button Triggers random 20-move scramble
#btn-undo Undo button Reverts the last move
#btn-reset Reset button Returns cube to solved state
#speed-slider Range input Controls animation speed (1=slow, 10=fast)
#shortcut-hint Text line Displays keyboard shortcut reference

4. CSS Styling — Complete Specification

4.1 Global / Body

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #0a0a0a;
  font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
  color: #e0e0e0;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
}

body:active {
  cursor: grabbing;
}

4.2 Canvas Container

#canvas-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}

#canvas-container canvas {
  display: block;
  width: 100%;
  height: 100%;
}

4.3 Control Panel — Frosted Glass

#control-panel {
  position: fixed;
  top: 16px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  background: rgba(20, 20, 25, 0.65);
  backdrop-filter: blur(20px) saturate(1.4);
  -webkit-backdrop-filter: blur(20px) saturate(1.4);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 16px;
  padding: 16px 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  min-width: 600px;
  max-width: 90vw;
  box-shadow: 
    0 8px 32px rgba(0, 0, 0, 0.5),
    0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}

4.4 Panel Title

#panel-title {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 3px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.5);
  margin-bottom: 2px;
}

4.5 Button Rows

.button-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 5px;
}

4.6 Move Buttons

.move-btn {
  background: rgba(255, 255, 255, 0.07);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  color: #d0d0d0;
  font-size: 13px;
  font-weight: 600;
  font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
  padding: 6px 12px;
  min-width: 38px;
  cursor: pointer;
  transition: all 0.15s ease;
  text-align: center;
}

.move-btn:hover {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.2);
  color: #ffffff;
  transform: translateY(-1px);
}

.move-btn:active {
  transform: translateY(0px);
  background: rgba(255, 255, 255, 0.2);
}

.slice-btn {
  background: rgba(120, 120, 255, 0.08);
  border-color: rgba(120, 120, 255, 0.15);
}

.slice-btn:hover {
  background: rgba(120, 120, 255, 0.18);
  border-color: rgba(120, 120, 255, 0.3);
}

4.7 Action Buttons

.action-btn {
  background: rgba(255, 255, 255, 0.07);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  color: #d0d0d0;
  font-size: 13px;
  font-weight: 500;
  padding: 6px 16px;
  cursor: pointer;
  transition: all 0.15s ease;
  white-space: nowrap;
}

.action-btn:hover {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.2);
  color: #ffffff;
  transform: translateY(-1px);
}

.action-btn:active {
  transform: translateY(0px);
}

#btn-scramble:hover {
  background: rgba(80, 180, 255, 0.15);
  border-color: rgba(80, 180, 255, 0.3);
}

#btn-reset:hover {
  background: rgba(255, 80, 80, 0.12);
  border-color: rgba(255, 80, 80, 0.25);
}

4.8 Speed Slider

#speed-control {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: 10px;
}

#speed-control label {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.4);
  font-weight: 500;
}

#speed-slider {
  -webkit-appearance: none;
  appearance: none;
  width: 80px;
  height: 4px;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 2px;
  outline: none;
  cursor: pointer;
}

#speed-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.6);
  border: none;
  cursor: pointer;
  transition: background 0.15s;
}

#speed-slider::-webkit-slider-thumb:hover {
  background: rgba(255, 255, 255, 0.85);
}

#speed-slider::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.6);
  border: none;
  cursor: pointer;
}

4.9 Keyboard Shortcut Hint

#shortcut-hint {
  font-size: 11px;
  color: rgba(255, 255, 255, 0.25);
  text-align: center;
  letter-spacing: 0.3px;
}

4.10 Responsive Adjustments

@media (max-width: 650px) {
  #control-panel {
    min-width: unset;
    width: calc(100% - 24px);
    padding: 12px 14px;
    top: 8px;
  }
  .move-btn {
    padding: 5px 8px;
    min-width: 32px;
    font-size: 12px;
  }
  .action-btn {
    font-size: 12px;
    padding: 5px 10px;
  }
}

5. Colour Specification

5.1 Rubik's Cube Face Colours

These are the standard Western colour scheme Rubik's Cube colours. Each should be a Three.js hex colour.

Face Position Hex Colour Three.js Hex
Right (+X) Right Red 0xC41E3A
Left (−X) Left Orange 0xFF5800
Up (+Y) Top White 0xFFFFFF
Down (−Y) Bottom Yellow 0xFFD500
Front (+Z) Front Green 0x009E60
Back (−Z) Back Blue 0x0051BA

5.2 Cube Body Colour

The inner plastic body (gaps between stickers) should be near-black: 0x111111

5.3 Scene / Environment Colours

Element Colour
Background / clear colour 0x080808
Ambient light 0x333340 (dim cool grey)
Spotlight colour 0xffffff (pure white)
Floor plane (optional subtle shadow catcher) 0x0a0a0a

6. JavaScript Logic — Complete Specification

6.0 Architecture Overview

The JavaScript is structured into these logical sections (all in one <script> block):

  1. Constants & Config — colours, sizes, speed settings
  2. Scene Setup — Three.js scene, camera, renderer, lights
  3. Cube Model — building the 27 cubies (3×3×3)
  4. Sticker System — attaching coloured face meshes to each cubie
  5. Move Logic — defining all 18 standard moves (R, R', L, L', U, U', D, D', F, F', B, B', M, M', E, E', S, S')
  6. Animation System — smooth rotation tweening for moves
  7. Drag Interaction — raycasting to detect face drags vs orbit drags
  8. History & Undo — storing move history for undo
  9. Scramble — random move generation
  10. Reset — restoring solved state
  11. UI Bindings — connecting buttons, slider, keyboard shortcuts
  12. Render Loop — animation frame loop

6.1 Constants & Config

// === CONSTANTS ===
const CUBIE_SIZE = 1;          // Each cubie is 1×1×1 unit
const GAP = 0.06;              // Gap between cubies (creates sticker look)
const CUBIE_SPACING = CUBIE_SIZE + GAP;  // Center-to-center distance
const STICKER_SIZE = 0.88;     // Sticker plane size (slightly smaller than cubie face)
const STICKER_OFFSET = 0.501;  // How far sticker sits from cubie center (just above surface)
const CORNER_RADIUS = 0.08;    // Rounded corner radius for cubies (visual only)

// Face colours — see Section 5.1
const FACE_COLOURS = {
  R: 0xC41E3A,   // Red     — Right  (+X)
  L: 0xFF5800,   // Orange  — Left   (−X)
  U: 0xFFFFFF,   // White   — Up     (+Y)
  D: 0xFFD500,   // Yellow  — Down   (−Y)
  F: 0x009E60,   // Green   — Front  (+Z)
  B: 0x0051BA,   // Blue    — Back   (−Z)
};

const BODY_COLOUR = 0x111111;  // Dark plastic body

// Speed: slider value 1–10 maps to animation duration
// Slider 1 = 600ms per move, Slider 10 = 80ms per move
function getAnimationDuration() {
  const sliderVal = parseInt(document.getElementById('speed-slider').value);
  return 660 - (sliderVal * 58);  // Range: ~600ms down to ~80ms
}

// Axis definitions for each face move
const MOVE_DEFS = {
  'R':  { axis: 'x', layer:  1, angle: -Math.PI / 2 },
  "R'": { axis: 'x', layer:  1, angle:  Math.PI / 2 },
  'L':  { axis: 'x', layer: -1, angle:  Math.PI / 2 },
  "L'": { axis: 'x', layer: -1, angle: -Math.PI / 2 },
  'U':  { axis: 'y', layer:  1, angle: -Math.PI / 2 },
  "U'": { axis: 'y', layer:  1, angle:  Math.PI / 2 },
  'D':  { axis: 'y', layer: -1, angle:  Math.PI / 2 },
  "D'": { axis: 'y', layer: -1, angle: -Math.PI / 2 },
  'F':  { axis: 'z', layer:  1, angle: -Math.PI / 2 },
  "F'": { axis: 'z', layer:  1, angle:  Math.PI / 2 },
  'B':  { axis: 'z', layer: -1, angle:  Math.PI / 2 },
  "B'": { axis: 'z', layer: -1, angle: -Math.PI / 2 },
  'M':  { axis: 'x', layer:  0, angle:  Math.PI / 2 },  // M follows L direction
  "M'": { axis: 'x', layer:  0, angle: -Math.PI / 2 },
  'E':  { axis: 'y', layer:  0, angle:  Math.PI / 2 },  // E follows D direction
  "E'": { axis: 'y', layer:  0, angle: -Math.PI / 2 },
  'S':  { axis: 'z', layer:  0, angle: -Math.PI / 2 },  // S follows F direction
  "S'": { axis: 'z', layer:  0, angle:  Math.PI / 2 },
};

6.2 Scene Setup

// === THREE.JS SCENE ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080808);

// Optional: very subtle fog for depth
scene.fog = new THREE.FogExp2(0x080808, 0.035);

// Camera — perspective, looking at cube from a slight angle
const camera = new THREE.PerspectiveCamera(
  40,                                      // FOV — narrower for less distortion
  window.innerWidth / window.innerHeight,  // Aspect
  0.1,                                     // Near
  100                                      // Far
);
camera.position.set(5, 4, 6);  // Slightly above, to the right
camera.lookAt(0, 0, 0);

// Renderer — antialias ON, high pixel ratio for crisp edges
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: false,
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.getElementById('canvas-container').appendChild(renderer.domElement);

Lighting

// Ambient light — dim, slightly cool
const ambient = new THREE.AmbientLight(0x333340, 0.6);
scene.add(ambient);

// Main spotlight — from above-right, casts shadow
const spotlight = new THREE.SpotLight(0xffffff, 40);
spotlight.position.set(5, 10, 5);
spotlight.angle = Math.PI / 5;
spotlight.penumbra = 0.6;
spotlight.decay = 1.5;
spotlight.distance = 40;
spotlight.castShadow = true;
spotlight.shadow.mapSize.width = 1024;
spotlight.shadow.mapSize.height = 1024;
spotlight.shadow.camera.near = 1;
spotlight.shadow.camera.far = 30;
spotlight.target.position.set(0, 0, 0);
scene.add(spotlight);
scene.add(spotlight.target);

// Secondary fill light — dimmer, from opposite side
const fillLight = new THREE.PointLight(0x4466aa, 3);
fillLight.position.set(-6, 3, -4);
scene.add(fillLight);

// Subtle rim/back light
const rimLight = new THREE.PointLight(0x222244, 2);
rimLight.position.set(0, -4, -6);
scene.add(rimLight);

Optional Floor Shadow Catcher

// Subtle floor plane to catch spotlight shadow
const floorGeo = new THREE.PlaneGeometry(20, 20);
const floorMat = new THREE.ShadowMaterial({ opacity: 0.35 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2;
floor.receiveShadow = true;
scene.add(floor);

6.3 Cube Model — Building the 27 Cubies

The cube is a 3×3×3 grid of small cubes ("cubies"). Each cubie is positioned at coordinates where x, y, z each ∈ {−1, 0, 1} (in terms of logical layer), but physically spaced by CUBIE_SPACING.

Data Structure

// Master array of all 27 cubies
const cubies = [];

// A group that holds ALL cubies — allows whole-cube rotation via orbit
const cubeGroup = new THREE.Group();
scene.add(cubeGroup);

// Temporary group used during move animation (holds the rotating layer)
const animGroup = new THREE.Group();
scene.add(animGroup);

Cubie Construction

function buildCube() {
  // Clear any existing cubies
  while (cubeGroup.children.length > 0) {
    cubeGroup.remove(cubeGroup.children[0]);
  }
  cubies.length = 0;

  // Rounded box geometry for each cubie body
  const cubieGeo = createRoundedBoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE, CORNER_RADIUS, 2);
  const cubieMat = new THREE.MeshStandardMaterial({
    color: BODY_COLOUR,
    roughness: 0.4,
    metalness: 0.05,
  });

  for (let x = -1; x <= 1; x++) {
    for (let y = -1; y <= 1; y++) {
      for (let z = -1; z <= 1; z++) {
        const cubie = new THREE.Mesh(cubieGeo, cubieMat);
        cubie.position.set(
          x * CUBIE_SPACING,
          y * CUBIE_SPACING,
          z * CUBIE_SPACING
        );
        cubie.castShadow = true;

        // Store the logical position on the cubie as userData
        cubie.userData.logicalPos = { x, y, z };

        // Attach stickers to outer faces
        attachStickers(cubie, x, y, z);

        cubeGroup.add(cubie);
        cubies.push(cubie);
      }
    }
  }
}

Rounded Box Geometry Helper

⚠ COMMON PITFALL: Three.js does not have a built-in RoundedBoxGeometry in the core library loaded from CDN. You must either: (a) Use a regular BoxGeometry (simpler, still looks fine), OR (b) Implement a simple rounded box via bevelled edges manually.

Recommendation: Use regular BoxGeometry for simplicity. The gap between cubies and the sticker planes already create the visual sticker effect. If you want rounded corners, use an ExtrudeGeometry with a rounded rectangle shape, but this adds complexity.

// Simple fallback if RoundedBoxGeometry is not available
function createRoundedBoxGeometry(w, h, d, r, segments) {
  // Use standard BoxGeometry — visually fine with stickers
  return new THREE.BoxGeometry(w, h, d);
}

6.4 Sticker System

Each cubie on the surface of the cube has coloured sticker planes on its outward-facing sides. A cubie at position (1, y, z) has a sticker on its +X face, etc.

function attachStickers(cubie, x, y, z) {
  const stickerGeo = new THREE.PlaneGeometry(STICKER_SIZE, STICKER_SIZE);

  // Helper: create a sticker and attach to cubie
  function addSticker(face, nx, ny, nz, rx, ry) {
    const mat = new THREE.MeshStandardMaterial({
      color: FACE_COLOURS[face],
      roughness: 0.3,
      metalness: 0.0,
      side: THREE.FrontSide,
    });
    const sticker = new THREE.Mesh(stickerGeo, mat);
    sticker.position.set(
      nx * STICKER_OFFSET,
      ny * STICKER_OFFSET,
      nz * STICKER_OFFSET
    );
    // Rotate the plane to face outward
    if (rx !== undefined) sticker.rotation.x = rx;
    if (ry !== undefined) sticker.rotation.y = ry;

    // Tag the sticker with its face identity (for raycasting)
    sticker.userData.isFaceSticker = true;
    sticker.userData.face = face;

    cubie.add(sticker);
  }

  // Right face (+X)
  if (x === 1)  addSticker('R',  1, 0, 0, 0, Math.PI / 2);
  // Left face (−X)
  if (x === -1) addSticker('L', -1, 0, 0, 0, -Math.PI / 2);
  // Up face (+Y)
  if (y === 1)  addSticker('U', 0,  1, 0, -Math.PI / 2, 0);
  // Down face (−Y)
  if (y === -1) addSticker('D', 0, -1, 0, Math.PI / 2, 0);
  // Front face (+Z)
  if (z === 1)  addSticker('F', 0, 0,  1, 0, 0);
  // Back face (−Z)
  if (z === -1) addSticker('B', 0, 0, -1, 0, Math.PI);
}

⚠ COMMON PITFALL: The sticker plane rotation must match the face normal. A plane by default faces +Z. To face +X, rotate Y by +90°. To face +Y, rotate X by −90°. Getting this wrong results in invisible or inward-facing stickers.


6.5 Move Logic

A "move" rotates a layer of 9 cubies around an axis by ±90°.

Selecting Cubies in a Layer

// Get all cubies whose current world position is in the given layer
function getCubiesInLayer(axis, layerValue) {
  const tolerance = 0.3;  // Generous tolerance for floating point
  const result = [];

  cubies.forEach(cubie => {
    // Get world position (important after rotations have been applied)
    const worldPos = new THREE.Vector3();
    cubie.getWorldPosition(worldPos);

    // Determine which axis coordinate to check
    let coord;
    if (axis === 'x') coord = worldPos.x;
    if (axis === 'y') coord = worldPos.y;
    if (axis === 'z') coord = worldPos.z;

    // layerValue is -1, 0, or 1 — but physical position is scaled by CUBIE_SPACING
    const targetCoord = layerValue * CUBIE_SPACING;

    if (Math.abs(coord - targetCoord) < tolerance) {
      result.push(cubie);
    }
  });

  return result;
}

⚠ CRITICAL PITFALL: After several moves, cubie world positions accumulate floating-point drift. The tolerance value (0.3) must be generous enough to handle this. If you use too tight a tolerance (like 0.01), cubies will be missed and the cube will break apart. After each animated move completes, you MUST snap cubie positions to the nearest grid position (see Section 6.6).

Executing a Move

let isAnimating = false;
const moveHistory = [];

function executeMove(moveName, addToHistory = true) {
  if (isAnimating) return;

  const def = MOVE_DEFS[moveName];
  if (!def) return;

  isAnimating = true;

  const layerCubies = getCubiesInLayer(def.axis, def.layer);

  // Reparent cubies into animGroup for rotation
  layerCubies.forEach(cubie => {
    // Save world transform before reparenting
    const worldPos = new THREE.Vector3();
    const worldQuat = new THREE.Quaternion();
    cubie.getWorldPosition(worldPos);
    cubie.getWorldQuaternion(worldQuat);

    cubeGroup.remove(cubie);
    animGroup.add(cubie);

    cubie.position.copy(worldPos);
    cubie.quaternion.copy(worldQuat);
  });

  // Reset animGroup rotation
  animGroup.rotation.set(0, 0, 0);

  // Animate the rotation
  animateRotation(def.axis, def.angle, layerCubies, () => {
    // Animation complete — reparent back to cubeGroup
    layerCubies.forEach(cubie => {
      const worldPos = new THREE.Vector3();
      const worldQuat = new THREE.Quaternion();
      cubie.getWorldPosition(worldPos);
      cubie.getWorldQuaternion(worldQuat);

      animGroup.remove(cubie);
      cubeGroup.add(cubie);

      cubie.position.copy(worldPos);
      cubie.quaternion.copy(worldQuat);

      // CRITICAL: Snap position and rotation to clean values
      snapCubie(cubie);
    });

    animGroup.rotation.set(0, 0, 0);

    if (addToHistory) {
      moveHistory.push(moveName);
    }

    isAnimating = false;
  });
}

Snapping After Move

function snapCubie(cubie) {
  // Snap position to nearest CUBIE_SPACING grid
  cubie.position.x = Math.round(cubie.position.x / CUBIE_SPACING) * CUBIE_SPACING;
  cubie.position.y = Math.round(cubie.position.y / CUBIE_SPACING) * CUBIE_SPACING;
  cubie.position.z = Math.round(cubie.position.z / CUBIE_SPACING) * CUBIE_SPACING;

  // Snap rotation: each Euler angle should be a multiple of PI/2
  const snap = (val) => Math.round(val / (Math.PI / 2)) * (Math.PI / 2);
  const euler = new THREE.Euler().setFromQuaternion(cubie.quaternion, 'XYZ');
  euler.x = snap(euler.x);
  euler.y = snap(euler.y);
  euler.z = snap(euler.z);
  cubie.quaternion.setFromEuler(euler);
}

⚠ CRITICAL PITFALL: Rotation snapping is the #1 source of bugs. Euler angles suffer from gimbal lock. A more robust approach is to round each element of the rotation matrix to 0, 1, or −1, then reconstruct the quaternion. The Euler-based snap above works for 90° increments but can occasionally produce wrong results after many moves. For a bulletproof solution, snap the rotation matrix instead:

function snapCubieRobust(cubie) {
  // Snap position
  cubie.position.x = Math.round(cubie.position.x / CUBIE_SPACING) * CUBIE_SPACING;
  cubie.position.y = Math.round(cubie.position.y / CUBIE_SPACING) * CUBIE_SPACING;
  cubie.position.z = Math.round(cubie.position.z / CUBIE_SPACING) * CUBIE_SPACING;

  // Snap rotation matrix elements to nearest integer (-1, 0, 1)
  const m = cubie.matrix;
  cubie.updateMatrixWorld(true);
  const e = cubie.matrixWorld.elements.slice();  // Copy

  // The upper-left 3×3 of the 4×4 matrix is the rotation
  // Matrix elements: [0,1,2, 4,5,6, 8,9,10] are the rotation part
  const rotIndices = [0, 1, 2, 4, 5, 6, 8, 9, 10];
  rotIndices.forEach(i => {
    e[i] = Math.round(e[i]);
  });

  // Rebuild from snapped matrix
  const snappedMatrix = new THREE.Matrix4();
  snappedMatrix.set(
    e[0], e[4], e[8],  cubie.position.x,
    e[1], e[5], e[9],  cubie.position.y,
    e[2], e[6], e[10], cubie.position.z,
    0,    0,    0,     1
  );
  cubie.quaternion.setFromRotationMatrix(snappedMatrix);
}

Use snapCubieRobust instead of snapCubie for reliability.


6.6 Animation System

Smooth rotation using requestAnimationFrame with an easing function.

let animStartTime = null;
let animCallback = null;
let animAxis = null;
let animAngle = 0;

function animateRotation(axis, angle, cubies, onComplete) {
  animStartTime = performance.now();
  animAxis = axis;
  animAngle = angle;
  animCallback = onComplete;
}

function updateAnimation(now) {
  if (animStartTime === null) return;

  const duration = getAnimationDuration();
  const elapsed = now - animStartTime;
  const t = Math.min(elapsed / duration, 1);

  // Ease-out cubic
  const eased = 1 - Math.pow(1 - t, 3);
  const currentAngle = animAngle * eased;

  // Apply rotation to animGroup
  animGroup.rotation.set(0, 0, 0);
  if (animAxis === 'x') animGroup.rotation.x = currentAngle;
  if (animAxis === 'y') animGroup.rotation.y = currentAngle;
  if (animAxis === 'z') animGroup.rotation.z = currentAngle;

  if (t >= 1) {
    // Ensure final rotation is exact
    animGroup.rotation.set(0, 0, 0);
    if (animAxis === 'x') animGroup.rotation.x = animAngle;
    if (animAxis === 'y') animGroup.rotation.y = animAngle;
    if (animAxis === 'z') animGroup.rotation.z = animAngle;

    animStartTime = null;
    const cb = animCallback;
    animCallback = null;
    if (cb) cb();
  }
}

6.7 Drag Interaction — Face Twist vs Orbit

This is the most complex part. The user needs two drag behaviours:

  1. Drag on a cube face sticker → twist that layer (detect which face was clicked, detect drag direction to determine which layer and direction)
  2. Drag on empty space or the cube body → orbit the whole camera around the cube

Raycaster Setup

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let dragStartCubie = null;
let dragStartFace = null;
let dragStartWorldPoint = null;
let hasDraggedLayer = false;

const DRAG_THRESHOLD = 8;  // Pixels before drag is registered

Mouse/Touch Event Handlers

renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointermove', onPointerMove);
renderer.domElement.addEventListener('pointerup', onPointerUp);

function onPointerDown(event) {
  if (isAnimating) return;
  if (event.target !== renderer.domElement) return;

  isDragging = true;
  hasDraggedLayer = false;
  dragStart = { x: event.clientX, y: event.clientY };

  // Raycast to check if we hit a sticker
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);

  // Collect all sticker meshes (children of cubies)
  const stickerMeshes = [];
  cubies.forEach(cubie => {
    cubie.children.forEach(child => {
      if (child.userData.isFaceSticker) {
        stickerMeshes.push(child);
      }
    });
  });

  const intersects = raycaster.intersectObjects(stickerMeshes, false);

  if (intersects.length > 0) {
    const hit = intersects[0];
    dragStartCubie = hit.object.parent;  // The cubie that owns this sticker
    dragStartFace = hit.object.userData.face;
    dragStartWorldPoint = hit.point.clone();
  } else {
    dragStartCubie = null;
    dragStartFace = null;
    dragStartWorldPoint = null;
  }
}

Determining Move from Drag Direction

When the user drags on a face sticker, we need to:

  1. Know which face they started on (R, L, U, D, F, B)
  2. Know the drag direction in screen space
  3. Map that to the correct Rubik's move
function onPointerMove(event) {
  if (!isDragging || isAnimating) return;

  const dx = event.clientX - dragStart.x;
  const dy = event.clientY - dragStart.y;
  const dist = Math.sqrt(dx * dx + dy * dy);

  if (dist < DRAG_THRESHOLD) return;

  if (dragStartCubie && dragStartFace && !hasDraggedLayer) {
    hasDraggedLayer = true;

    // Determine the move based on face and drag direction
    const move = determineMoveFromDrag(dragStartCubie, dragStartFace, dx, dy);
    if (move) {
      executeMove(move, true);
    }

    isDragging = false;
    return;
  }

  // If not on a sticker, do camera orbit
  if (!dragStartCubie) {
    orbitCamera(dx, dy);
    dragStart = { x: event.clientX, y: event.clientY };
  }
}

function onPointerUp() {
  isDragging = false;
  dragStartCubie = null;
  dragStartFace = null;
}

Move Determination Logic

⚠ COMMON PITFALL: Mapping screen-space drag direction to a 3D layer rotation is non-trivial because it depends on the current camera angle. The mapping table below assumes a "standard" camera view, but the camera can orbit. A robust implementation projects the drag vector into 3D space relative to the face normal.

Simplified approach (works well for typical camera angles):

function determineMoveFromDrag(cubie, face, dx, dy) {
  const worldPos = new THREE.Vector3();
  cubie.getWorldPosition(worldPos);

  // Determine which layer this cubie is in for each axis
  const lx = Math.round(worldPos.x / CUBIE_SPACING);
  const ly = Math.round(worldPos.y / CUBIE_SPACING);
  const lz = Math.round(worldPos.z / CUBIE_SPACING);

  // Use dominant drag direction
  const absDx = Math.abs(dx);
  const absDy = Math.abs(dy);
  const horizontal = absDx > absDy;

  // This is a simplified mapping. For a production cube, you'd project
  // the drag vector onto the face's tangent plane and use dot products.
  // This simplified version works well for typical camera positions.

  let moveName = null;

  switch (face) {
    case 'F': // Front face: horizontal drag = U/D layer, vertical drag = R/L layer
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('y', ly, false) : layerToMove('y', ly, true);
      } else {
        moveName = dy > 0 ? layerToMove('x', lx, true) : layerToMove('x', lx, false);
      }
      break;
    case 'B': // Back face: reversed horizontal
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('y', ly, true) : layerToMove('y', ly, false);
      } else {
        moveName = dy > 0 ? layerToMove('x', lx, false) : layerToMove('x', lx, true);
      }
      break;
    case 'R': // Right face: horizontal = F/B layer via z, vertical = U/D layer via y
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('y', ly, false) : layerToMove('y', ly, true);
      } else {
        moveName = dy > 0 ? layerToMove('z', lz, false) : layerToMove('z', lz, true);
      }
      break;
    case 'L': // Left face
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('y', ly, true) : layerToMove('y', ly, false);
      } else {
        moveName = dy > 0 ? layerToMove('z', lz, true) : layerToMove('z', lz, false);
      }
      break;
    case 'U': // Up face: horizontal and vertical map to x and z layers
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('z', lz, true) : layerToMove('z', lz, false);
      } else {
        moveName = dy > 0 ? layerToMove('x', lx, true) : layerToMove('x', lx, false);
      }
      break;
    case 'D': // Down face
      if (horizontal) {
        moveName = dx > 0 ? layerToMove('z', lz, false) : layerToMove('z', lz, true);
      } else {
        moveName = dy > 0 ? layerToMove('x', lx, false) : layerToMove('x', lx, true);
      }
      break;
  }

  return moveName;
}

function layerToMove(axis, layer, clockwise) {
  // Maps axis + layer + direction to a move name
  if (axis === 'x') {
    if (layer === 1)  return clockwise ? 'R' : "R'";
    if (layer === -1) return clockwise ? "L'" : 'L';
    if (layer === 0)  return clockwise ? "M'" : 'M';
  }
  if (axis === 'y') {
    if (layer === 1)  return clockwise ? 'U' : "U'";
    if (layer === -1) return clockwise ? "D'" : 'D';
    if (layer === 0)  return clockwise ? "E'" : 'E';
  }
  if (axis === 'z') {
    if (layer === 1)  return clockwise ? 'F' : "F'";
    if (layer === -1) return clockwise ? "B'" : 'B';
    if (layer === 0)  return clockwise ? 'S' : "S'";
  }
  return null;
}

⚠ NOTE ON DRAG ACCURACY: The simplified drag mapping above assumes a roughly standard viewing angle. If the camera orbits to view the cube from underneath or behind, the horizontal/vertical screen mapping to 3D axes becomes inaccurate. A fully correct implementation would:

  1. Project the screen drag vector into 3D world space
  2. Project it onto the plane of the clicked face
  3. Determine which of the two possible rotation axes on that face the drag aligns with
  4. Use the dot product sign for direction

This is significantly more complex but essential for a production-quality cube. Consider implementing the simpler version first and upgrading later.

Camera Orbit

// Simple orbit: rotate cubeGroup instead of moving the camera
// This keeps the lighting static relative to the world
function orbitCamera(dx, dy) {
  const sensitivity = 0.005;
  cubeGroup.rotation.y += dx * sensitivity;
  cubeGroup.rotation.x += dy * sensitivity;

  // Clamp X rotation to avoid flipping
  cubeGroup.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, cubeGroup.rotation.x));
}

⚠ NOTE: Orbiting by rotating cubeGroup means the cube's logical axes rotate with it, which complicates the face-drag detection. An alternative is to use Three.js OrbitControls on the camera itself. However, OrbitControls and manual face-drag detection can conflict. Choose one approach:

Option A (recommended for simplicity): Rotate cubeGroup for orbit, accept that drag-to-twist will be approximate at extreme camera angles.

Option B (more robust): Use OrbitControls for the camera, disable them during face drags, and convert screen coordinates to world coordinates using the camera's actual orientation for move determination.


6.8 History & Undo

function getInverseMove(moveName) {
  if (moveName.endsWith("'")) {
    return moveName.slice(0, -1);  // R' → R
  } else {
    return moveName + "'";          // R → R'
  }
}

function undo() {
  if (moveHistory.length === 0 || isAnimating) return;
  const lastMove = moveHistory.pop();
  const inverse = getInverseMove(lastMove);
  executeMove(inverse, false);  // false = don't add to history
}

6.9 Scramble

function scramble() {
  if (isAnimating) return;

  const basicMoves = ['R', "R'", 'L', "L'", 'U', "U'", 'D', "D'", 'F', "F'", 'B', "B'"];
  const scrambleMoves = [];
  let lastAxis = '';

  for (let i = 0; i < 20; i++) {
    let move;
    let axis;
    do {
      move = basicMoves[Math.floor(Math.random() * basicMoves.length)];
      axis = move[0];  // First character is the face letter
    } while (axis === lastAxis);  // Avoid consecutive same-axis moves

    scrambleMoves.push(move);
    lastAxis = axis;
  }

  // Execute scramble moves sequentially
  executeMovesSequence(scrambleMoves);
}

function executeMovesSequence(moves, index = 0) {
  if (index >= moves.length) return;
  // Wait for current animation to finish, then execute next
  const originalDuration = document.getElementById('speed-slider').value;
  document.getElementById('speed-slider').value = 9;  // Speed up scramble

  executeMove(moves[index], true);

  // Poll until animation completes, then do next
  const check = setInterval(() => {
    if (!isAnimating) {
      clearInterval(check);
      if (index < moves.length - 1) {
        executeMovesSequence(moves, index + 1);
      } else {
        document.getElementById('speed-slider').value = originalDuration;  // Restore speed
      }
    }
  }, 10);
}

6.10 Reset

function resetCube() {
  if (isAnimating) return;

  // Remove all cubies from groups
  while (cubeGroup.children.length > 0) {
    const child = cubeGroup.children[0];
    cubeGroup.remove(child);
    // Dispose geometry/material to prevent memory leaks
    if (child.geometry) child.geometry.dispose();
    if (child.material) child.material.dispose();
    child.children.forEach(c => {
      if (c.geometry) c.geometry.dispose();
      if (c.material) c.material.dispose();
    });
  }
  while (animGroup.children.length > 0) {
    animGroup.remove(animGroup.children[0]);
  }

  // Rebuild from scratch
  buildCube();

  // Clear history
  moveHistory.length = 0;
}

6.11 UI Bindings

Button Click Handlers

// Move buttons
document.querySelectorAll('.move-btn').forEach(btn => {
  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    const move = btn.getAttribute('data-move');
    executeMove(move, true);
  });
});

// Action buttons
document.getElementById('btn-scramble').addEventListener('click', (e) => {
  e.stopPropagation();
  scramble();
});

document.getElementById('btn-undo').addEventListener('click', (e) => {
  e.stopPropagation();
  undo();
});

document.getElementById('btn-reset').addEventListener('click', (e) => {
  e.stopPropagation();
  resetCube();
});

Keyboard Shortcuts

document.addEventListener('keydown', (e) => {
  if (isAnimating) return;

  // Prevent shortcuts when typing in inputs
  if (e.target.tagName === 'INPUT') return;

  const key = e.key.toUpperCase();
  const shift = e.shiftKey;

  // Face moves: R, L, U, D, F, B — Shift for prime
  if (['R', 'L', 'U', 'D', 'F', 'B'].includes(key)) {
    const move = shift ? key + "'" : key;
    executeMove(move, true);
    return;
  }

  // Middle slices: M, E, S — Shift for prime
  if (['M', 'E', 'S'].includes(key)) {
    const move = shift ? key + "'" : key;
    executeMove(move, true);
    return;
  }

  // Scramble
  if (e.code === 'Space') {
    e.preventDefault();
    scramble();
    return;
  }

  // Undo: Ctrl+Z / Cmd+Z
  if ((e.ctrlKey || e.metaKey) && key === 'Z') {
    e.preventDefault();
    undo();
    return;
  }

  // Reset: Escape
  if (e.key === 'Escape') {
    resetCube();
    return;
  }
});

Prevent Panel Dragging Interfering with Cube

document.getElementById('control-panel').addEventListener('pointerdown', (e) => {
  e.stopPropagation();
});

6.12 Render Loop

function animate(now) {
  requestAnimationFrame(animate);
  updateAnimation(now);
  renderer.render(scene, camera);
}

// Window resize handler
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// Initialize
buildCube();
animate(performance.now());

7. User Interactions — Complete Reference

Action Interaction Result
Twist a face layer Click and drag on a coloured sticker on the cube The layer containing that cubie rotates 90° in the drag direction
Orbit the cube Click and drag on empty space (not on a sticker) The whole cube rotates to show different angles
Click a move button Click any button in the control panel (R, R', L, etc.) That move is executed with smooth animation
Scramble Click "⟳ Scramble" button OR press Space 20 random moves are applied rapidly
Undo Click "↩ Undo" button OR press Ctrl+Z / Cmd+Z The last move is reversed
Reset Click "✕ Reset" button OR press Escape Cube returns to the solved state, history is cleared
Change speed Drag the speed slider Animation speed changes from slow (1) to fast (10)
Keyboard move Press R, L, U, D, F, B, M, E, or S Executes the corresponding clockwise move
Keyboard prime move Hold Shift + press a move key Executes the counter-clockwise (prime) version

8. Things That Are Easy to Get Wrong

Here is a numbered list of the most common pitfalls, in order of how likely they are to cause problems:

  1. Floating-point drift after moves: After several rotations, cubie positions drift from clean grid values. If you don't snap positions and rotations after EVERY move animation completes, cubies will gradually misalign and layer selection will fail. This is the #1 cause of "the cube breaks after a few moves."

  2. Euler angle gimbal lock during snapping: Snapping Euler angles to multiples of π/2 can produce wrong orientations due to gimbal lock. Use the rotation matrix snapping approach (snapCubieRobust) instead.

  3. Sticker plane orientation: Each sticker PlaneGeometry faces +Z by default. You must rotate it to face outward from the cubie. Getting the rotation wrong by even π radians means the sticker faces inward and is invisible.

  4. Reparenting cubies during animation: When you move cubies from cubeGroup to animGroup for the twist animation, their world positions must be preserved. Use getWorldPosition() and getWorldQuaternion() before and after reparenting. If you skip this, cubies will jump to wrong positions.

  5. OrbitControls vs face drag conflict: If using Three.js OrbitControls, they will consume pointer events and prevent face-drag detection. You must disable OrbitControls during face drags and re-enable after. The simpler approach (rotating cubeGroup) avoids this entirely but means the lighting moves with the cube.

  6. Layer selection tolerance: The tolerance for matching cubies to layers must account for floating-point error. Use 0.3 or wider. Too tight and cubies are missed; the animation rotates fewer than 9 cubies.

  7. Scramble speed: If scramble moves play at full animation speed, the scramble takes 10+ seconds. Speed up the slider temporarily during scramble, or queue moves to execute instantly without animation.

  8. Move direction for drag: Mapping screen-space (dx, dy) to 3D rotation direction depends on the camera orientation. The simplified mapping table works for the default camera angle but becomes wrong at extreme orbit angles. A production solution requires projecting the drag into 3D space.

  9. Event propagation: Clicks on the control panel buttons must not trigger cube dragging. Use stopPropagation() on the panel's pointerdown event.

  10. Memory leaks on reset: When rebuilding the cube, dispose of old geometries and materials. Three.js doesn't garbage-collect GPU resources automatically.

  11. CDN script loading order: three.min.js must load before any code that uses THREE. Place the script tag in the <head> or use defer carefully. Your main <script> block at the end of <body> ensures Three.js has loaded.

  12. Prime move notation: In HTML, the ' character in data-move="R'" can cause attribute parsing issues if you use single quotes for the attribute. Always use double quotes for HTML attributes: data-move="R'".


9. Visual Quality Checklist

Before considering the build complete, verify:


10. Summary of Architecture

┌─────────────────────────────────────────────┐
│                 index.html                   │
│                                              │
│  ┌───────────────┐  ┌────────────────────┐  │
│  │   <style>     │  │   HTML Structure   │  │
│  │   All CSS     │  │   #canvas-container│  │
│  │   Glass panel │  │   #control-panel   │  │
│  │   Buttons     │  │   Buttons/Slider   │  │
│  └───────────────┘  └────────────────────┘  │
│                                              │
│  ┌──────────────────────────────────────┐   │
│  │            <script>                   │   │
│  │                                       │   │
│  │  Three.js Scene ─┐                   │   │
│  │    Camera         │                   │   │
│  │    Lights         │                   │   │
│  │    Floor          │                   │   │
│  │                   │                   │   │
│  │  cubeGroup ───────┤                   │   │
│  │    27 cubies      │ ← snapped after   │   │
│  │      each with    │   every move      │   │
│  │      stickers     │                   │   │
│  │                   │                   │   │
│  │  animGroup ───────┘ ← temp during     │   │
│  │    (9 cubies        twist animation)  │   │
│  │     during move)                      │   │
│  │                                       │   │
│  │  Move System                          │   │
│  │    MOVE_DEFS → axis + layer + angle   │   │
│  │    getCubiesInLayer()                 │   │
│  │    executeMove() → animate → snap     │   │
│  │                                       │   │
│  │  Input System                         │   │
│  │    Raycaster → sticker detection      │   │
│  │    Drag → face twist OR orbit         │   │
│  │    Keyboard → move dispatch           │   │
│  │    Buttons → move dispatch            │   │
│  │                                       │   │
│  │  History: moveHistory[] + undo()      │   │
│  │  Scramble: 20 random non-redundant    │   │
│  │  Reset: rebuild from scratch          │   │
│  │                                       │   │
│  │  Render Loop: requestAnimationFrame   │   │
│  └──────────────────────────────────────┘   │
│                                              │
└─────────────────────────────────────────────┘

END OF BUILD PROMPT

This document contains everything needed to build the complete 3D Rubik's Cube. Follow each section in order. Do not skip the snapping logic, do not skip the reparenting world-transform preservation, and test with 50+ random moves to verify stability.