cubed
Rubik's cube solver using Kociemba's two-phase algorithm
A 2x2 Rubik's Cube that scrambles and solves itself in the browser. The cube state lives in Rust compiled to WebAssembly, and the rendering happens in React Three Fiber. The interesting part is how little code it takes when you pick the right abstractions.
Moves as permutations
A Rubik's Cube is a permutation puzzle. Every move is just a rearrangement of where colors end up. The entire cube state is a 24-character string — four facelets per face, six faces:
const SOLVED: &str = "UUUULLLLFFFFRRRRBBBBDDDD";
pub struct Cube {
facelets: [Face; 24],
}A move doesn't simulate physics. It's a lookup table: for each of the 24 positions, where does the facelet at that position come from?
pub fn take_action(&mut self, action: Action) -> &mut Self {
let facelets = self.facelets.clone();
let indices = action.indices();
for i in 0..24 {
self.facelets[i] = facelets[indices[i]];
}
self
}That's the entire move engine. action.indices() returns a [usize; 24] permutation array. Applying a move is just remapping. This means an inverse is trivial — reverse the move list and flip each direction:
pub fn inverse(&self) -> Self {
match self {
Action::Move(m) => Action::from_move(m.face, m.direction.opposite()),
Action::Rotate(r) => Action::from_rotation(r.axis, r.direction.opposite()),
}
}Computing permutations
Each move permutes two things: the four facelets on the turning face, and the eight edge facelets adjacent to it. The face rotation is a 4-element cycle:
fn rotate_face_indices(indices: [usize; 4], direction: Direction) -> [usize; 4] {
let order = match direction {
Direction::Clockwise => [2, 0, 3, 1],
Direction::CounterClockwise => [1, 3, 0, 2],
};
let mut result = [0; 4];
for i in 0..result.len() {
result[i] = indices[order[i]];
}
result
}The edge adjacency is defined per face — which facelets from neighboring faces get pulled along:
pub fn adjacent_edge_indices(&self) -> [[usize; 2]; 4] {
match self {
Face::Up => [
Face::Front.edge_indices(Edge::Top),
Face::Left.edge_indices(Edge::Top),
Face::Back.edge_indices(Edge::Top),
Face::Right.edge_indices(Edge::Top),
],
Face::Front => [
Face::Up.edge_indices(Edge::Bottom),
Face::Right.edge_indices(Edge::Left),
Face::Down.edge_indices(Edge::Top),
Face::Left.edge_indices(Edge::Right),
],
// ...
}
}These two pieces compose into the full 24-element permutation for any move. No mutation, no special cases — group theory does the work.
Scrambling without dependencies
Scrambles use a bare xorshift64 PRNG. No rand crate, no external dependencies in the core:
pub fn scramble(n: usize, seed: u64) -> (Cube, Vec<Action>) {
let mut cube = Cube::default();
let mut moves = Vec::with_capacity(n);
let mut rng = seed;
let mut prev_face: Option<Face> = None;
while moves.len() < n {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
let idx = (rng % ALL_MOVES.len() as u64) as usize;
let (face, direction) = ALL_MOVES[idx];
if prev_face == Some(face) {
continue;
}
let action = Action::from_move(face, direction);
cube.take_action(action);
moves.push(action);
prev_face = Some(face);
}
(cube, moves)
}Skipping consecutive same-face moves avoids redundant sequences like R R'. The solve is just the inverse of the scramble moves played back.
Crossing the WASM boundary
The Rust core compiles to WebAssembly via wasm-bindgen. The entire API surface is four functions:
#[wasm_bindgen]
pub fn solved_state() -> String {
Cube::default().to_string()
}
#[wasm_bindgen]
pub fn apply_move(state: &str, action: &str) -> Result<String, JsValue> {
let mut cube: Cube = state.parse()
.map_err(|e: CubeError| JsValue::from_str(&e.to_string()))?;
let action: Action = action.parse()
.map_err(|e: String| JsValue::from_str(&e))?;
cube.take_action(action);
Ok(cube.to_string())
}
#[wasm_bindgen]
pub fn scramble(num_moves: u32, seed: u32) -> JsValue {
let (cube, moves) = core_scramble(num_moves as usize, seed as u64);
let result = ScrambleResult {
state: cube.to_string(),
moves: moves.iter().map(|m| m.to_string()).collect(),
};
serde_wasm_bindgen::to_value(&result).unwrap()
}State crosses the boundary as a string. Moves cross as standard notation strings (R, L', U). The TypeScript side never needs to understand cube internals.
Mapping state to 3D
The 24-character string needs to become colored faces on 8 cubies in 3D space. Each cubie has exactly 3 visible faces, so there's a direct mapping:
const CUBIE_FACELET_MAP: [number, number, number][] = [
[5, 2, 8], // ULF -> L[1], U[2], F[0]
[12, 3, 9], // URF -> R[0], U[3], F[1]
[4, 0, 17], // ULB -> L[0], U[0], B[1]
[13, 1, 16], // URB -> R[1], U[1], B[0]
[7, 20, 10], // DLF -> L[3], D[0], F[2]
[14, 21, 11], // DRF -> R[2], D[1], F[3]
[6, 22, 19], // DLB -> L[2], D[2], B[3]
[15, 23, 18], // DRB -> R[3], D[3], B[2]
];
export function parseCubeState(state: string) {
return CUBIE_FACELET_MAP.map(([xi, yi, zi]) => [
state[xi], state[yi], state[zi],
]);
}Each cubie renders as a black box with three colored planes floating just above each face:
export default function Cubie({ position, faces }: CubieProps) {
const [x, y, z] = position;
return (
<group position={position}>
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#1d2021" />
</mesh>
<Facelet face={faces[0]} position={[sign(x) * 0.501, 0, 0]}
rotation={[0, Math.PI / 2, 0]} />
<Facelet face={faces[1]} position={[0, sign(y) * 0.501, 0]}
rotation={[Math.PI / 2, 0, 0]} />
<Facelet face={faces[2]} position={[0, 0, sign(z) * 0.501]} />
</group>
);
}Colors are from the Gruvbox palette to match the site theme:
const GRUVBOX_COLORS: Record<FaceletColor, string> = {
U: "#d79921", // yellow
D: "#b16286", // purple
F: "#d65d0e", // orange
B: "#458588", // blue
R: "#98971a", // green
L: "#cc241d", // red
};Animating layer turns
When a move is in progress, the cube splits into two groups — the four cubies in the turning layer, and the four that stay still. The layer group rotates over time with cubic easing:
useFrame((_, delta) => {
if (!moveInfo || !layerRef.current) return;
progressRef.current = Math.min(
progressRef.current + delta / animationDuration, 1
);
const t = progressRef.current;
const eased = t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
const angle = moveInfo.angle * eased;
layerRef.current.rotation.set(
moveInfo.layer.axis === "x" ? angle : 0,
moveInfo.layer.axis === "y" ? angle : 0,
moveInfo.layer.axis === "z" ? angle : 0,
);
});Standard cube notation maps directly to Three.js rotation axes:
const MOVE_LAYERS: Record<string, MoveLayerInfo> = {
R: { cubieIndices: [1, 3, 5, 7], axis: "x", cwAngle: -Math.PI / 2 },
L: { cubieIndices: [0, 2, 4, 6], axis: "x", cwAngle: Math.PI / 2 },
U: { cubieIndices: [0, 1, 2, 3], axis: "y", cwAngle: -Math.PI / 2 },
D: { cubieIndices: [4, 5, 6, 7], axis: "y", cwAngle: Math.PI / 2 },
F: { cubieIndices: [0, 1, 4, 5], axis: "z", cwAngle: -Math.PI / 2 },
B: { cubieIndices: [2, 3, 6, 7], axis: "z", cwAngle: Math.PI / 2 },
};The loop
The animation loop is an async state machine: scramble, pause, solve, pause, repeat. Each phase feeds moves to the cube one at a time:
const runLoop = useCallback(async () => {
await sleep(pauseMs);
const seed = Math.floor(Math.random() * 0xffffffff);
const { moves } = scramble(numMoves, seed);
const solveMoves = inverseMoves(moves);
let current = solvedState();
for (const move of moves) {
setAnimState({ cubeState: current, pendingMove: move });
await sleep(moveDelayMs);
current = applyMove(current, move);
}
await sleep(pauseMs);
for (const move of solveMoves) {
setAnimState({ cubeState: current, pendingMove: move });
await sleep(moveDelayMs);
current = applyMove(current, move);
}
}, [ready, numMoves, moveDelayMs, pauseMs]);Each applyMove call crosses into WASM, updates the 24-character state string, and React re-renders the affected cubies. The whole thing runs in a requestAnimationFrame loop at 60fps.