isometric-rolling-cube
This skill should be used when the user is working on rolling cube animation, edge-pivoting, isometric cube movement, transform-origin animation, Object3D reparenting for rotation, grid-based cube wandering, window boundary enforcement, direction selection logic, cursor following, or weighted random direction bias — even if they don't explicitly mention "rolling cube." Covers both CSS transform-origin and Three.js Object3D pivot approaches, GSAP animation chaining, cumulative rotation tracking, grid snapping, boundary checking, and cursor-biased direction selection.
From lll-animationnpx claudepluginhub simon-tanna/lll-animation-plugin --plugin lll-animationThis skill uses the workspace's default tool permissions.
references/boundary-algorithm.mdreferences/direction-data.mdIsometric Rolling Cube
A cube "rolls" by tipping 90 degrees over one of its bottom edges, translating one grid cell in that direction. This is fundamentally different from rotating around the centre — the pivot point must be at the contact edge, not the object's origin.
This skill covers the technique for both CSS (Phase 1) and Three.js (Phase 2) implementations, with GSAP driving the animation in both cases.
Invocation Behaviour
When invoked without a specific topic, prompt for context:
Which aspect of rolling animation are you working on?
Topic Covers Rolling / pivot CSS transform-origin or Three.js Object3D pivot technique Directions The 4 isometric roll directions and data tables Boundaries Window boundary enforcement and direction filtering Cursor Cursor-following bias and weighted random selection Snapping Grid snapping and floating-point drift prevention Reparenting Three.js attach()vsadd()and the pivot cycleOr describe what you need help with.
When a specific topic is identified, jump directly to the relevant section. For CSS cube construction (faces, preserve-3d, isometric projection), delegate to the phase1-css-cube skill. For GSAP API specifics, delegate to the gsap-expert skill.
The 4 Isometric Roll Directions
In isometric view, the cube moves diagonally on screen. Each screen direction maps to a world-axis movement:
| Screen Direction | World Direction (XZ) | Grid Delta |
|---|---|---|
| Top-Right | +X | (1, 0) |
| Bottom-Right | +Z | (0, 1) |
| Bottom-Left | -X | (-1, 0) |
| Top-Left | -Z | (0, -1) |
For a unit cube (side length S), each direction has a corresponding rotation axis, pivot offset, and translation vector.
Direction Data (Three.js — Y-up, XZ ground plane)
For a cube centred at (gx, S/2, gz):
| Direction | Translation | Pivot Offset from Centre | Rotation Axis | Angle |
|---|---|---|---|---|
| +X | (S, 0, 0) | (+S/2, -S/2, 0) | (0, 0, -1) | PI/2 |
| -X | (-S, 0, 0) | (-S/2, -S/2, 0) | (0, 0, +1) | PI/2 |
| +Z | (0, 0, S) | (0, -S/2, +S/2) | (+1, 0, 0) | PI/2 |
| -Z | (0, 0, -S) | (0, -S/2, -S/2) | (-1, 0, 0) | PI/2 |
The rotation axis can be derived: cross(direction, downVector) where downVector = (0, -1, 0).
The pivot offset is always: half a cube-width toward the roll direction on the ground axis, plus half a cube-width downward (to the bottom edge).
Direction Data (CSS — pre-isometric flat plane)
Full transform-origin values and translation vectors for all 4 directions: see references/direction-data.md.
Key principle: transform-origin must be a 3D value (three components) targeting the specific bottom edge for each direction. A 2D value places the pivot at Z=0, which is wrong for the Bottom-Right and Top-Left directions (those need a non-zero Z component).
Phase 1: CSS Rolling Technique
How It Works
Each roll is a simple 90-degree rotation animated with GSAP. The key is the reset step after each roll — this keeps each animation starting from a clean state.
For each roll:
- Animate a 90-degree rotation in the roll direction (using GSAP)
- On complete (the reset): move the element to its new grid position, zero out the rotation, and the cube is ready for the next roll
The Reset Step (Core Technique)
After a roll completes, the cube has rotated 90 degrees. Absorb this into the base state:
- Move the element to its new grid position (CSS
left/toportranslate) - Zero out the roll rotation
- This prevents cumulative transform stacking
Without this reset, each successive roll compounds on the previous rotation and the animation breaks after 2-3 rolls. The reset is what makes the entire approach work — each roll starts from a clean, predictable state.
Cumulative State
Because each roll resets to a clean state, no complex rotation tracking is needed. The logical grid position (gridX, gridY) is the only state to maintain between rolls — update it in the onComplete callback after each reset.
Phase 2: Three.js Pivot Technique
The Object3D Reparenting Cycle
Three.js has no transform-origin equivalent. Instead, create a temporary pivot Object3D at the rolling edge and make the cube a child of it. Rotating the pivot then rotates the cube around that edge.
The full cycle for one roll:
Step 1 — Create pivot at the rolling edge:
Position a new Object3D at the cube's bottom edge in the roll direction.
Step 2 — Attach cube to pivot:
Use pivot.attach(cube) — this preserves the cube's world-space position while making it a child of the pivot. The cube does not visually move.
Step 3 — Animate pivot rotation: Use GSAP to rotate the pivot 90 degrees around the appropriate axis. The cube, as a child, orbits around the pivot point.
Step 4 — Detach cube from pivot:
Use scene.attach(cube) — this moves the cube back to the scene's children while preserving its current world position and rotation.
Step 5 — Clean up: Remove the pivot from the scene. Snap the cube's position to exact grid coordinates.
Why attach() Not add()
This is the single most important API distinction in the entire implementation.
Object3D.add(child)— reparents the child but keeps its local transform unchanged. Since the local transform is now interpreted relative to the new parent, the child's world position changes. The cube would visually jump.Object3D.attach(child)— reparents the child and recalculates its local transform so that its world position stays the same. Internally it computes:newLocal = inverse(newParent.worldMatrix) * oldParent.worldMatrix * oldLocal.
Use attach() for both directions: cube-to-pivot and pivot-back-to-scene.
Rotation Reset After Each Roll
After each roll's reparenting cleanup, reset the cube's rotation to clean values. Since each roll is exactly 90 degrees, all rotation components should be exact multiples of PI/2.
Recommended approach: Reset Euler angles to clean values after each roll in the onComplete callback. Round each rotation component to the nearest multiple of PI/2. This keeps every roll starting from a predictable state — no cumulative drift, no gimbal lock issues.
Optional advanced approach: Use quaternions (Quaternion.premultiply()) to track cumulative rotation if you need precise face-orientation tracking (e.g., knowing which face is on top). This is not needed for the workshop's rolling behaviour.
GSAP Animation Patterns
Per-Roll Timeline with onComplete Chaining
Each roll is an independent animation. Since directions are chosen randomly, you cannot pre-build a long timeline. Instead, chain rolls dynamically:
- Pick a random valid direction
- Create a short timeline (or single tween) for one roll
- In the
onCompletecallback, clean up the pivot/transform state, then call the roll function again (possibly after a delay)
Use gsap.delayedCall(pauseDuration, rollNext) to insert pauses between rolls — this is cleaner than adding empty delays to timelines.
Targeting Three.js Objects with GSAP
Target the sub-object, not the mesh itself:
- Position:
gsap.to(mesh.position, { x: ..., z: ... }) - Rotation:
gsap.to(pivot.rotation, { z: angle })
Do NOT use dot-path syntax on the mesh: gsap.to(mesh, { "rotation.x": angle }) — this does not work with Three.js objects.
Easing
"power2.inOut"gives a natural roll-and-settle feel"power1.out"(GSAP default) also works well for a lighter landing- Experiment encouraged — the "right" ease is subjective
The Landing Jump (yoyo)
Add a natural bounce at each landing with yoyo: true:
// Runs alongside the roll animation — plays up then automatically reverses
gsap.to(positionContainer, {
y: -jumpHeight,
duration: rollDuration / 2,
yoyo: true,
repeat: 1,
ease: "power2.out"
})
yoyo: true with repeat: 1 plays the tween forward then automatically reverses — the position container rises then falls back, producing the characteristic up-then-down bounce at each landing.
Render Loop
GSAP tweens values but does not trigger Three.js renders. Three options:
- Continuous rAF loop —
requestAnimationFramethat callsrenderer.render()every frame. Simple, always works. (spec preferred — the boilerplate provides this) - GSAP onUpdate — call
renderer.render()inside each tween'sonUpdate. Renders only during animation but misses static scene updates. - gsap.ticker —
gsap.ticker.add(() => renderer.render(scene, camera)). Synchronises rendering with GSAP's update cycle. Good middle ground.
Grid Snapping
Floating-point arithmetic causes gradual drift after many rolls. After each roll completes (in the onComplete callback), snap the cube's position to exact grid coordinates:
- Three.js:
gsap.set(mesh.position, { x: Math.round(mesh.position.x), z: Math.round(mesh.position.z) })— usinggsap.set()rather than direct assignment keeps GSAP's internal state consistent - CSS: Set the element's position to
gridX * cellSize,gridY * cellSizeand clear any transform residue
Track position as integer grid coordinates (gridX, gridY) alongside the rendered position. Update the grid coordinates after each roll, and snap the rendered position to match.
Boundary Enforcement
Before each roll, filter valid directions against grid bounds. See references/boundary-algorithm.md for the full direction filtering algorithm, CSS viewport-pixel bounds approach, and Three.js grid-coordinate bounds approach.
Cursor Following
Weighted direction bias toward cursor position. See references/boundary-algorithm.md for the full bias algorithm, CSS screen-space approach, and Three.js Vector3.unproject() conversion for world-space cursor coordinates.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Pivot at centre, not edge | Cube spins in place | Position pivot at bottom edge using offset formula |
Using add() instead of attach() | Cube jumps when reparented | Always use attach() for both directions |
| Euler angle accumulation | Unexpected rotation after 3-4 rolls | Reset rotation to clean multiples of PI/2 after each roll |
| No grid snap | Cube drifts off grid over time | gsap.set() to round position in onComplete |
| Pre-built timeline for random directions | Can't randomise; timeline is fixed | Use onComplete chaining, one roll per timeline |
GSAP directional shortcuts (_cw, _short) | Ignored or breaks on Three.js objects | These only work on DOM targets — use raw angle values |
| No render loop | Scene doesn't update visually | Add gsap.ticker.add(() => renderer.render(...)) |
| Modifying boilerplate camera/scene | Breaks grid alignment | The starter repo's camera and grid are pre-configured — don't touch them |
See Also
phase1-css-cube— CSS cube construction (Desandro 6-div approach, face transforms, preserve-3d, isometric projection)workshop-guide— Task progression, acceptance criteria, phase transitionsgsap-expert— Full GSAP API reference, timeline patterns, ScrollTriggerthreejs-fundamentals— Scene setup, Object3D hierarchy, coordinate system, math utilities