An educational robotics app running in the browser. A 3-axis industrial robot arm is controlled by a Claude AI brain. Users type natural-language commands; Claude generates keyframe motion plans; a spring-damper motor controller executes them at 60Hz through a forward-kinematics chain. The robot can physically interact with boxes via Rapier physics.
User command (natural language)
│
▼
/api/brain ──► Claude Sonnet 4.6
Tool: execute_movement
│
▼
Keyframe array [{time_ms, joints}]
│
▼
motorController.startPlan(keyframes)
│
useFrame() @ 60Hz
│
┌─────────────┴──────────────┐
│ Interpolate between │
│ surrounding keyframes │
│ (linear lerp per joint) │
│ │ │
│ applySpring(joint, target)│ ← spring-damper per DOF
│ │ │
│ clampToLimits(joint, val) │ ← hard joint limit enforcement
│ │ │
│ applyMotorTarget(joint) │ ← writes to Three.js Group rotation
└─────────────────────────── ┘
│
▼
FK chain propagates through scene graph
│
┌─────────────┴──────────────┐
│ gripperTip.getWorldPos() │ → liveGripperState
│ armSegs.getWorldPos/Quat()│ → liveArmTransforms
└─────────────────────────── ┘
│
▼
BoxArena (Rapier useBeforePhysicsStep)
distance check → grab → kinematic carry → release
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router) |
| 3D rendering | React Three Fiber v9 + @react-three/drei |
| Physics | @react-three/rapier v2.1 (Rapier WASM) |
| AI brain | Claude Sonnet 4.6 via Anthropic API, tool_use forced output |
| State | Zustand v5 with subscribeWithSelector |
| Memory | localStorage via Zustand persist |
| Styling | Tailwind CSS v4 + shadcn/ui |
The arm is built entirely from Three.js geometric primitives — no imported models.
Base plate (cylinder)
└── Base rotator [Y-axis, ±π×4 — full spin]
└── Shoulder joint [X-axis, -0.1 … 1.40 rad]
└── Upper arm (box)
└── Elbow joint [X-axis, -0.2 … 1.80 rad]
└── Forearm (box)
└── Wrist joint [X-axis, -2.2 … 1.6 rad]
└── Gripper palm (box)
├── Left fork jaw [Z-axis, 0 … +0.7 rad]
└── Right fork jaw [Z-axis, -0.7 … 0 rad]
Each joint is a Three.js <group> whose rotation axis is set by jointRegistry. The motor controller writes target angles to the registry; the FK chain propagates automatically through the scene graph.
Gripper: two-fork parallel gripper. Each jaw has a vertical prong pair (front + back, offset in ±Z) with rubber grip pads. Closed when leftFinger < 0.08 rad.
The core loop runs inside React Three Fiber's useFrame at ~60Hz. No React state is written during this loop — only JS module-level singletons.
Each DOF (degree of freedom) has independent position and velocity state:
acc = (target - cur) * k - vel * d
vel += acc * dt
cur += vel * dtSpring parameters per joint family:
| Joint | Stiffness (k) | Damping (d) | Feel |
|---|---|---|---|
| baseRotate | 22 | 7 | Slightly underdamped — natural swing |
| shoulder | 32 | 9 | Underdamped — visible overshoot |
| elbow | 38 | 10 | Stiff, quick |
| wrist | 30 | 8 | Medium |
| fingers | 55 | 20 | Overdamped — no oscillation on grip |
Fingers are overdamped (ζ > 1) deliberately: underdamped fingers oscillate across the grip threshold (0.08 rad), causing phantom release/grab cycles.
Claude returns an array of keyframes {time_ms, joints}. The controller linearly interpolates between the two surrounding keyframes each tick. After the last keyframe the pose is held in lastJoints so the arm doesn't snap back to rest.
clampToLimits is called every tick after the spring step. This prevents spring overshoot from ever exceeding the physical joint bounds — the arm cannot pass through the floor regardless of spring tuning.
25 wooden crates (0.28m cube, 1.5kg each) are dynamic Rapier RigidBody instances.
Grab logic (runs in useBeforePhysicsStep):
- Each frame, read gripper tip world position from
liveGripperState - On rising edge of
isGripping: find nearest box within 0.5m - Switch box to
kinematicPosition, setheldIdx - Every frame while held:
setNextKinematicTranslationto gripper tip position - On release: debounce 12 frames (~200ms) before switching back to
dynamic
The debounce absorbs the underdamped finger spring briefly crossing the 0.08 rad threshold on approach, which would otherwise cause immediate release.
Claude is called once per command via a POST to /api/brain. The response is a single execute_movement tool call with:
{
movement_plan: string, // reasoning
keyframes: [{
time_ms: number,
joints: {
[JointName]: number | [number, number, number]
}
}],
educational_note: string, // shown in UI
success_criteria: string
}tool_choice: { type: "tool", name: "execute_movement" } forces structured output — no free-text fallback.
The system prompt (~2000 tokens) is marked with cache_control: { type: "ephemeral" } and includes the full joint schema, coordinate system, IK guide, and few-shot examples. Prompt caching makes repeat calls significantly cheaper.
Past successful movements are stored in localStorage via Zustand persist. On each new command, the 3 most similar past movements are retrieved using Jaccard similarity on tokenized command strings and injected into the Claude prompt. Claude is explicitly asked to build upon them.
A game-controller style panel lets you jog individual joints with buttons or keyboard:
| Key | Joint | Direction |
|---|---|---|
| Q / E | Base rotate | CCW / CW |
| W / S | Shoulder | Up / Down |
| R / F | Elbow | Extend / Fold |
| T / G | Wrist | Up / Down |
| C / V | Grip | Open / Close |
| 0 | — | Reset all boxes |
Key repeat is implemented with a 50ms setInterval holding a Set<string> of active keys — no reliance on browser key-repeat timing.
Reads liveBoxPositions (populated each frame by BoxArena) at 5Hz. Shows the 8 nearest boxes with distance and coordinates. "Pick Up" buttons submit natural-language commands to the AI brain.
ANTHROPIC_API_KEY=sk-ant-...
npm install
npm run devRequires Node 20+. Rapier WASM is loaded asynchronously on first render (~2MB). The canvas is wrapped in <Suspense> to handle this.
src/
├── app/
│ ├── page.tsx # Main UI
│ ├── live/page.tsx # Read-only visitor view (SSE)
│ └── api/
│ ├── brain/route.ts # Claude keyframe generation
│ └── sse/route.ts # Live viewer sync
├── components/
│ ├── robot/RobotArm.tsx # FK arm assembly + gripper
│ ├── scene/
│ │ ├── HumanoidScene.tsx # R3F Canvas + Physics wrapper
│ │ ├── BoxArena.tsx # 25 physics boxes + grab logic
│ │ ├── Room.tsx # 7×7×2.8m lab room geometry
│ │ └── ArmPhysicsBody.tsx # Kinematic Rapier bodies tracking FK
│ └── ui/
│ ├── CommandInput.tsx
│ ├── ManualControlPanel.tsx
│ ├── VisionPanel.tsx
│ ├── JointReadouts.tsx
│ ├── KeyframeTimeline.tsx
│ └── MemoryPanel.tsx
├── lib/
│ ├── physics/
│ │ ├── motorController.ts # Spring-damper FK driver
│ │ ├── jointRegistry.ts # Joint name → Three.js Group map
│ │ ├── liveGripperState.ts # Gripper tip world position
│ │ ├── liveArmTransforms.ts # All segment world transforms
│ │ └── liveBoxPositions.ts # Box world positions for vision
│ └── constants/
│ ├── jointLimits.ts # Joint bounds + REST_POSE
│ └── bodyDimensions.ts
└── store/
├── robotStore.ts # Joint readouts, plan status, UI flags
└── memoryStore.ts # Movement library + localStorage persist