← Back to the library

PROMPT3D & design

Brief: a Rubik's cube that scrambles and solves itself in three.js

The brief and build notes behind the cube on this homepage: 386 instanced pieces, real slice turns, a reversed move list to solve, and shape morphs in between.

WHAT YOU’LL GET

A self-solving black and white Rubik's cube with real slice turns and shape morphs, built with three.js alone and one instanced mesh.

WHO IT’S FOR

Builders adding a 3D moment to a site with an AI coding agent.

DIFFICULTY

Intermediate

TIME

2 hours

WORKS WITH

Claude Codex

Get the full file

The whole resource as one Markdown file for your notes or your AI workspace.

FREE

What you will build

A 9×9×9 Rubik's cube in three.js that plays a loop on its own: the solved cube spins, whole slices turn to scramble it, the pieces burst into a floating cloud, reform as a shape (sphere, helix, wave wall or ring, a different one each loop), come back together as the scrambled cube, and then solve themselves by playing the scramble backwards. It is black and white, glossy, sits on a soft floor shadow, and uses no extra packages beyond three.js.

This is the brief and the build notes behind the cube on the dainer.ai homepage. Change the shapes, colours and timings to make it yours.

Before you start

  • three.js installed (npm install three). This build used three 0.185 and two addons that ship with it: RoundedBoxGeometry and RoomEnvironment.
  • A page or React component with a sized container for the canvas (the site uses a 3:2 box).
  • Claude Code or Codex, and a way to capture screenshots at phone and desktop sizes.
  • Node.js, to run the small logic test in Step 4.

How it works

Only the pieces you can see are built. A 9×9×9 cube has 729 positions, but the 343 inside are never visible, so the scene holds the 386 surface pieces. All of them are drawn by one InstancedMesh, which means one draw call for the whole cube.

Each piece keeps two pieces of state: an integer position from -4 to 4 on each axis, and a rotation stored as a quaternion. A slice turn rotates every piece whose coordinate on the turn axis equals the layer number. When a turn finishes, the new positions are rounded back to integers. That rounding is what keeps the cube exact after hundreds of turns, and it is why the solve can simply replay the scramble in reverse.

  1. Build the 386 surface cells, one rounded box geometry and six sticker materials.
  2. Scramble with 22 random slice turns, animating the turning slice and baking each finished turn.
  3. Morph every piece from its cube pose to a cloud pose, then to a shape pose, then back.
  4. Undo the scramble with the reversed, inverted move list.
  5. Spin the whole cube on a turntable, move the camera out for large shapes, and keep a soft shadow under it.
  6. Stop when hidden, paused or off screen, and show one still solved frame for reduced motion.

Step 1: Give your agent the brief

TEXT
Replace the about-strip photograph with a 9x9 Rubik's cube built in three.js.

Rules:
- Black and white only: black rounded pieces, six monochrome stickers (white, pale grey, mid grey, charcoal, hatch, dots) so adjacent faces always contrast.
- It must behave like a real cube: whole slices turn 90 degrees to scramble it, then the same turns play in reverse to solve it.
- Between scramble and solve, the pieces burst into a floating cloud, reform as a shape (sphere, helix, wave wall, ring, a different one each loop), then return to the scrambled cube.
- Steady showcase spin, a soft floor shadow, glossy reflections from an environment map, a quick celebratory spin when solved.
- three.js only, one instanced mesh, surface pieces only (386 of 729), no video, no new package.
- Respect prefers-reduced-motion (show the solved cube still) and the site's Pause motion control. No box or border around it.
- Build, lint, capture every stage at 390 and 1440, commit.

Check

  • The plan mentions one InstancedMesh, integer coordinates and a reversed move list. If it plans to rotate pieces randomly, stop it: that does not read as a Rubik's cube.
  • The plan does not add a package. RoundedBoxGeometry ships with three.js.

Step 2: Build the surface pieces and one instanced mesh

Loop over every position and keep only those on the outer shell, where at least one coordinate is -4 or 4.

TS
const R = 4; // coordinates run -4..4
const cells = [];
for (let x = -R; x <= R; x++) for (let y = -R; y <= R; y++) for (let z = -R; z <= R; z++)
  if (Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) === R) cells.push(x, y, z);
const COUNT = cells.length / 3;            // 386
const coord = new Int8Array(cells);        // integer positions
const quat = new Float32Array(COUNT * 4);  // one quaternion per piece
for (let i = 0; i < COUNT; i++) quat[i * 4 + 3] = 1;

const geo = new RoundedBoxGeometry(0.96, 0.96, 0.96, 3, 0.1);
const mesh = new THREE.InstancedMesh(geo, materials, COUNT);

The box is 0.96 wide on a grid of 1, which leaves the thin dark gap that makes pieces read as separate. RoundedBoxGeometry extends BoxGeometry, which builds six material groups in the order +x, -x, +y, -y, +z, -z. Passing an array of six materials therefore paints one sticker per face direction.

Check

  • Log COUNT. It must be 386.
  • After the first render, every piece shows the same sticker on the same world face.

Step 3: Make six stickers that stay readable in greyscale

Each sticker is a 128 px canvas: black plastic, a rounded inset sticker, and on two faces a pattern. Patterns matter in a black and white cube, because two greys next to each other at an edge can look like one colour under glossy light.

TS
const tones = [
  { fill: "#3b3d39" },                   // +x charcoal
  { fill: "#8e8f88" },                   // -x mid grey
  { fill: "#f3f1ea" },                   // +y white
  { fill: "#c9c8c0" },                   // -y pale grey
  { fill: "#f3f1ea", pattern: "hatch" }, // +z hatch
  { fill: "#e6e4dc", pattern: "dots" },  // -z dots
];
const textures = tones.map(({ fill, pattern }) => {
  const c = document.createElement("canvas");
  c.width = c.height = 128;
  const g = c.getContext("2d");
  g.fillStyle = "#0d0e0d"; g.fillRect(0, 0, 128, 128);
  g.beginPath(); g.roundRect(9, 9, 110, 110, 18); g.fillStyle = fill; g.fill();
  // hatch: diagonal strokes; dots: a grid of small circles (clipped to the sticker)
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  t.anisotropy = 8;
  return t;
});
const materials = textures.map((map) => new THREE.MeshPhysicalMaterial({
  map, envMap: envTex, roughness: 0.4, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.1, envMapIntensity: 0.85,
}));

envTex comes from pmrem.fromScene(new RoomEnvironment(), 0.04).texture, set on each material. Setting it per material is what makes envMapIntensity take effect in three.js 0.185.

Check

  • Look at any corner of the solved cube: the three visible faces must be clearly different.
  • Textures use SRGBColorSpace. Without it the tones render lighter and flatter than their hex values.

Step 4: Scramble with real slice turns and bake each turn

A move is an axis (0, 1 or 2), a layer (-4 to 4) and a direction. The scramble avoids turning the same slice twice in a row. bake applies a finished quarter turn: it rotates the positions of every piece in the layer, rounds them back to integers and multiplies the turn into each piece's quaternion.

TS
const scramble = (n) => {
  const list = [];
  while (list.length < n) {
    const m = { axis: Math.floor(rand() * 3), layer: Math.floor(rand() * 9) - R, dir: rand() < 0.5 ? 1 : -1 };
    const p = list[list.length - 1];
    if (p && p.axis === m.axis && p.layer === m.layer) continue;
    list.push(m);
  }
  return list;
};
const bake = (m) => {
  qm.setFromAxisAngle(AX[m.axis], (m.dir * Math.PI) / 2);
  for (let i = 0; i < COUNT; i++) {
    if (coord[i * 3 + m.axis] !== m.layer) continue;
    v.set(coord[i * 3], coord[i * 3 + 1], coord[i * 3 + 2]).applyQuaternion(qm);
    coord[i * 3] = Math.round(v.x); coord[i * 3 + 1] = Math.round(v.y); coord[i * 3 + 2] = Math.round(v.z);
    q.fromArray(quat, i * 4).premultiply(qm).normalize().toArray(quat, i * 4);
  }
};
const moves = scramble(22);
const undo = moves.slice().reverse().map((m) => ({ ...m, dir: m.dir * -1 }));

We tested this logic in Node with three.js 0.185: after 22 random moves and the reversed list, every one of the 386 pieces was back on its starting coordinate. Run the same test before you touch the rendering. Put scramble, bake and the arrays in a plain .mjs file, apply moves, apply undo, and compare coord with a copy taken at the start.

Check

  • The Node test prints that all coordinates match after the undo.
  • Coordinates stay integers. Log a few after 100 random moves.

Step 5: Animate the turning slice

While a move plays, only the pieces in its layer rotate, by a partial angle that eases from 0 to 90 degrees. Pieces outside the layer are drawn from their baked pose.

TS
const m = p.list[doneMoves];
const k = m ? ease(clamp((local - doneMoves * p.per) / (p.per * 0.92))) : 0;
if (m) qm.setFromAxisAngle(AX[m.axis], (m.dir * Math.PI * k) / 2);
for (let i = 0; i < COUNT; i++) {
  pose("cube", i, time, pa, qa);
  if (m && coord[i * 3 + m.axis] === m.layer) { pa.applyQuaternion(qm); qa.premultiply(qm); }
  m4.compose(pa, qa, one);
  mesh.setMatrixAt(i, m4);
}
mesh.instanceMatrix.needsUpdate = true;

The frame function bakes any moves whose time has passed before drawing, so a slow frame never skips a turn. Scramble moves take 0.19 s each and solve moves 0.12 s, so the solve feels quicker than the scramble.

Check

  • Slow the move time to 1 s and watch one turn. Only one slice moves, and it ends exactly flush.
  • Remove needsUpdate = true briefly: the cube freezes. That confirms the flag is doing its job.

Step 6: Morph into a cloud and a shape

Every form is a function that returns a position and a rotation for piece i at time t: the cube (its baked pose), the cloud (random points in a shell, slowly bobbing and tumbling) and four shapes. A morph blends two forms with lerp for position and slerp for rotation, with a random delay per piece so they do not move in lockstep.

TS
const k = clamp(local / p.dur);
for (let i = 0; i < COUNT; i++) {
  const kk = ease(clamp((k - delay[i] * 0.3) / 0.7));
  pose(p.from, i, time, pa, qa);
  pose(p.to, i, time, pb, qb);
  pa.lerp(pb, kk);
  qa.slerp(qb, kk);
  m4.compose(pa, qa, one);
  mesh.setMatrixAt(i, m4);
}

The sphere spreads pieces evenly with a golden-angle spiral and turns each piece to face outwards with setFromUnitVectors. The helix puts alternate pieces on two strands. The wave wall is a 20-column grid with a moving sine surface. The ring stacks four lanes of 64 pieces.

TS
// sphere pose for piece i
const k = i + 0.5, phi = Math.acos(1 - (2 * k) / COUNT), th = Math.PI * (1 + Math.sqrt(5)) * k;
outP.set(Math.cos(th) * Math.sin(phi), Math.cos(phi), Math.sin(th) * Math.sin(phi)).multiplyScalar(6.6);
outQ.setFromUnitVectors(up, v2.copy(outP).normalize());

Check

  • Capture the cloud and the shape. Pieces should arrive at slightly different times, not as one block.
  • The cube that reforms from the cloud is still scrambled. The morph reads from baked coordinates, so it cannot lose the scramble.

Step 7: One timeline, a turntable and a shadow

Each loop is a list of phases, rebuilt with a new scramble and the next shape every time:

  1. Hold the solved cube for 2.2 s.
  2. Scramble: 22 moves at 0.19 s.
  3. Hold for 1.0 s.
  4. Cube to cloud in 1.6 s, cloud to shape in 1.8 s.
  5. Hold the shape for 2.6 s.
  6. Shape to cloud in 1.3 s, cloud to cube in 1.9 s.
  7. Solve: the 22 undo moves at 0.12 s.
  8. A 0.4 s hold with a fast spin as the celebration.

Each phase also carries a spin rate. The group's rotation eases towards that rate, so the turntable speeds up and slows down smoothly. The camera distance and shadow size ease towards per-form targets (40 units for the cube, 58 for the cloud), so large shapes never leave the frame. The shadow is a plane with a radial-gradient canvas texture, depthWrite: false, scaled and faded with the cube's bob.

Check

  • Over four loops you see the sphere, helix, wave wall and ring, in that order.
  • Nothing crops at 390 px wide during the cloud phase.

Step 8: Accessibility, pause and cleanup

The container has role="img" and an aria-label that describes the loop in one sentence, because a canvas has no text of its own. The same observers used by the hero wordmark handle the rest: an IntersectionObserver for off-screen, visibilitychange for hidden tabs, and a MutationObserver on the site's data-motion attribute for the pause control. With reduced motion the loop never starts and the first frame, the solved cube, stays on screen. On unmount, dispose the geometry, all six materials and textures, the shadow, the environment texture, the PMREM generator, the instanced mesh and the renderer.

Check

  • Reduced motion on: a still, solved cube.
  • Pause control: rendering stops. Unpause: it continues without a jump.
  • A screen reader announces the label once and nothing else.

Step 9: Capture every stage

The capture script waits for fixed moments after the cube scrolls into view and screenshots only the cube. It computes each wait from the start time, so small delays do not add up.

JS
await p.locator(".about-cube").scrollIntoViewIfNeeded();
const t0 = Date.now();
for (const [name, at] of [["solved", 1.2], ["scrambling", 4.2], ["scrambled", 7.0], ["cloud", 9.3], ["shape", 12.2], ["reform", 16.2], ["solving", 18.2]]) {
  const wait = at * 1000 - (Date.now() - t0);
  if (wait > 0) await p.waitForTimeout(wait);
  await p.locator(".about-cube").screenshot({ path: `cube-${name}.png` });
}

Check

  • Seven images that show seven different stages, in light mode at 1440 and dark mode at 390.
  • No page errors in the console output.

Gotchas we hit

  • Random rotations read as noise. Rotating each piece on its own does not look like a Rubik's cube. Real slice turns do.
  • Floating-point drift. Rotating positions over and over leaves values like 3.9999998, and the layer test coord === layer starts missing pieces. Rounding to integers after every finished turn removes the drift completely.
  • Greys merged at the edges. In greyscale, two mid tones on adjacent faces looked like one block. Charcoal, white and a bold hatch on the three positive faces fixed it.
  • A tumble looked like a screensaver. A steady turntable spin with a slight fixed tilt looks like a product on display.
  • The first version was flat. It was pure CSS: three isometric faces of 81 facelets. Feedback asked for a full spin, shine, and pieces that break up and reform, which needed real 3D.
  • We drew pieces nobody could see. The next version instanced all 729 pieces. Only the 386 on the surface are ever visible, so the inside was dropped.

Take it further

  • Add your own shape: write one shapePose branch that returns a position and rotation for piece i.
  • Use colour stickers for a classic cube by changing the six fill values.
  • Try a smaller 3×3×3 cube by setting R = 1. The same code gives 26 pieces.
  • Read Brief: turn your logo text into floating 3D letters for the camera and lifecycle pattern in more detail.
  • Read three.js: why your black 3D text looks grey for the environment-map fix.
  • Read AI-assisted visual QA to make Step 9 part of every change.

Quick checklist

  • 386 surface pieces, one InstancedMesh, one rounded box geometry.
  • Six sticker materials in +x, -x, +y, -y, +z, -z order, each with its own envMap.
  • Slice turns baked into integer coordinates; the Node undo test passes.
  • instanceMatrix.needsUpdate = true after every frame's matrix updates.
  • Morphs use lerp and slerp with a per-piece delay.
  • Camera distance and shadow follow the current form.
  • Reduced motion shows a still solved cube; pause, hidden tab and off-screen stop rendering.
  • Every stage captured at 390 and 1440 with no page errors.