diff --git a/apps/web/.claude/ANIMATION_PATTERNS.md b/apps/web/.claude/ANIMATION_PATTERNS.md
new file mode 100644
index 00000000..7f869a5d
--- /dev/null
+++ b/apps/web/.claude/ANIMATION_PATTERNS.md
@@ -0,0 +1,111 @@
+# Animation Patterns
+
+## Spring-for-Speed, Manual-Integration-for-Angle Pattern
+
+When animating continuous rotation where the **speed changes smoothly** but you need to **avoid position jumps**, use this pattern.
+
+### The Problem
+
+**CSS Animation approach fails because:**
+- Changing `animation-duration` resets the animation phase, causing jumps
+- `animation-delay` tricks don't reliably preserve position across speed changes
+
+**Calling `spring.start()` 60fps fails because:**
+- React-spring's internal batching can't keep up with 60fps updates
+- Spring value lags 1000+ degrees behind, causing wild spinning
+- React re-renders interfere with spring updates
+
+### The Solution: Decouple Speed and Angle
+
+```typescript
+import { animated, useSpringValue } from '@react-spring/web'
+
+// 1. Spring for SPEED (this is what transitions smoothly)
+const rotationSpeed = useSpringValue(0, {
+ config: { tension: 200, friction: 30 },
+})
+
+// 2. Spring value for ANGLE (we'll .set() this directly, no springing)
+const rotationAngle = useSpringValue(0)
+
+// 3. Update speed spring when target changes
+useEffect(() => {
+ rotationSpeed.start(targetSpeedDegPerSec)
+}, [targetSpeedDegPerSec, rotationSpeed])
+
+// 4. requestAnimationFrame loop integrates angle from speed
+useEffect(() => {
+ let lastTime = performance.now()
+ let frameId: number
+
+ const loop = (now: number) => {
+ const dt = (now - lastTime) / 1000 // seconds
+ lastTime = now
+
+ const speed = rotationSpeed.get() // deg/s from the spring
+ let angle = rotationAngle.get() + speed * dt // integrate
+
+ // Keep angle in reasonable range (prevent overflow)
+ if (angle >= 360000) angle -= 360000
+ if (angle < 0) angle += 360
+
+ // Direct set - no extra springing on angle itself
+ rotationAngle.set(angle)
+
+ frameId = requestAnimationFrame(loop)
+ }
+
+ frameId = requestAnimationFrame(loop)
+ return () => cancelAnimationFrame(frameId)
+}, [rotationSpeed, rotationAngle])
+
+// 5. Bind angle to animated element
+ `rotate(${a}deg)`),
+ }}
+>
+ {/* SVG content */}
+
+```
+
+### Why This Works
+
+1. **Speed spring handles smooth transitions**: When target speed changes, the spring smoothly interpolates. No jumps.
+
+2. **Manual integration preserves continuity**: `angle += speed * dt` always adds to the current angle. The angle never resets or jumps.
+
+3. **Direct `.set()` avoids lag**: We're not asking the spring to animate the angle - we're directly setting it 60 times per second. No batching issues.
+
+4. **`useSpringValue` enables binding**: Unlike a plain ref, `useSpringValue` can be bound to animated elements via `.to()`.
+
+### Key Insights
+
+- **Spring the derivative, integrate the value**: Speed is the derivative of angle. Spring the speed, integrate to get angle.
+- **Never spring something you're updating 60fps**: The spring can't keep up. Use `.set()` instead of `.start()`.
+- **Keep integration in rAF, not React effects**: React effects can skip frames or batch. rAF is reliable.
+
+### When to Use This Pattern
+
+- Rotating elements where rotation speed changes based on state
+- Scrolling effects where scroll speed should transition smoothly
+- Any continuous animation where the RATE of change should animate, not the value itself
+
+### Anti-Patterns to Avoid
+
+```typescript
+// BAD: Calling start() in rAF loop
+const loop = () => {
+ angle.start(currentAngle + speed * dt) // Will lag behind!
+}
+
+// BAD: CSS animation with dynamic duration
+style={{
+ animation: `spin ${1/speed}s linear infinite` // Jumps on speed change!
+}}
+
+// BAD: Changing animation-delay to preserve position
+style={{
+ animationDelay: `-${currentAngle / 360 * duration}s` // Unreliable!
+}}
+```
diff --git a/apps/web/.claude/CLAUDE.md b/apps/web/.claude/CLAUDE.md
index 5c725e0b..a1df17f0 100644
--- a/apps/web/.claude/CLAUDE.md
+++ b/apps/web/.claude/CLAUDE.md
@@ -629,6 +629,40 @@ import { AbacusReact } from '@soroban/abacus-react'
**Status:** Known issue, does not block development or deployment.
+## Animation Patterns (React-Spring)
+
+When implementing continuous animations with smoothly-changing speeds (like rotating crosshairs), refer to:
+
+- **`.claude/ANIMATION_PATTERNS.md`** - Spring-for-speed, manual-integration-for-angle pattern
+ - Why CSS animation and naive react-spring approaches fail
+ - How to decouple speed (spring-animated) from angle (manually integrated)
+ - Complete code example with `useSpringValue` and `requestAnimationFrame`
+ - Anti-patterns to avoid
+
+**Quick Reference:**
+
+```typescript
+// Spring the SPEED, integrate the ANGLE
+const rotationSpeed = useSpringValue(0, { config: { tension: 200, friction: 30 } })
+const rotationAngle = useSpringValue(0)
+
+// Update speed spring when target changes
+useEffect(() => { rotationSpeed.start(targetSpeed) }, [targetSpeed])
+
+// rAF loop integrates angle from speed
+useEffect(() => {
+ const loop = (now) => {
+ const dt = (now - lastTime) / 1000
+ rotationAngle.set(rotationAngle.get() + rotationSpeed.get() * dt)
+ requestAnimationFrame(loop)
+ }
+ requestAnimationFrame(loop)
+}, [])
+
+// Bind to animated element
+ `rotate(${a}deg)`) }} />
+```
+
## Game Settings Persistence
When working on arcade room game settings, refer to:
diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
index c776aa5d..1a81ce2d 100644
--- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
+++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
@@ -1,6 +1,6 @@
'use client'
-import { animated, to, useSpring } from '@react-spring/web'
+import { animated, to, useSpring, useSpringValue } from '@react-spring/web'
import { css } from '@styled/css'
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -1314,45 +1314,56 @@ export function MapRenderer({
effectiveHotColdEnabled
)
- // Debounced rotation state to prevent flicker from feedback type flickering
- // Start rotating immediately when speed > 0, but delay stopping by 150ms
- const rawShouldRotate = crosshairHeatStyle.rotationSpeed > 0
- const [debouncedShouldRotate, setDebouncedShouldRotate] = useState(false)
- const stopTimeoutRef = useRef | null>(null)
+ // === Crosshair rotation using spring-for-speed, manual-integration-for-angle pattern ===
+ // This gives smooth speed transitions without the issues of CSS animation or
+ // calling spring.start() 60 times per second.
+ //
+ // Pattern:
+ // 1. Spring animates the SPEED (degrees per second) - smooth transitions
+ // 2. requestAnimationFrame loop integrates angle from speed
+ // 3. Angle is bound to animated element via useSpringValue
+ // Convert rotation speed from degrees/frame@60fps to degrees/second
+ const targetSpeedDegPerSec = crosshairHeatStyle.rotationSpeed * 60
+
+ // Spring for rotation speed - this is what makes speed changes smooth
+ const rotationSpeed = useSpringValue(0, {
+ config: { tension: 200, friction: 30 },
+ })
+
+ // Spring value for the angle - we'll directly .set() this from the rAF loop
+ const rotationAngle = useSpringValue(0)
+
+ // Update the speed spring when target changes
useEffect(() => {
- if (rawShouldRotate) {
- // Start immediately
- if (stopTimeoutRef.current) {
- clearTimeout(stopTimeoutRef.current)
- stopTimeoutRef.current = null
- }
- setDebouncedShouldRotate(true)
- } else {
- // Delay stopping to prevent flicker
- if (!stopTimeoutRef.current) {
- stopTimeoutRef.current = setTimeout(() => {
- setDebouncedShouldRotate(false)
- stopTimeoutRef.current = null
- }, 150)
- }
- }
- }, [rawShouldRotate])
+ rotationSpeed.start(targetSpeedDegPerSec)
+ }, [targetSpeedDegPerSec, rotationSpeed])
- // Cleanup timeout on unmount
+ // requestAnimationFrame loop to integrate angle from speed
useEffect(() => {
- return () => {
- if (stopTimeoutRef.current) {
- clearTimeout(stopTimeoutRef.current)
- }
- }
- }, [])
+ let lastTime = performance.now()
+ let frameId: number
- // Calculate CSS animation duration from rotation speed
- // rotationSpeed is degrees per frame at 60fps
- // duration = 360 degrees / (speed * 60 frames) seconds
- const rotationDuration =
- crosshairHeatStyle.rotationSpeed > 0 ? 360 / (crosshairHeatStyle.rotationSpeed * 60) : 1 // fallback, won't be used when paused
+ const loop = (now: number) => {
+ const dt = (now - lastTime) / 1000 // seconds
+ lastTime = now
+
+ const speed = rotationSpeed.get() // deg/s from the spring
+ let angle = rotationAngle.get() + speed * dt // integrate
+
+ // Keep angle in reasonable range (prevent overflow after hours of play)
+ if (angle >= 360000) angle -= 360000
+ if (angle < 0) angle += 360
+
+ // Direct set - no extra springing on angle itself
+ rotationAngle.set(angle)
+
+ frameId = requestAnimationFrame(loop)
+ }
+
+ frameId = requestAnimationFrame(loop)
+ return () => cancelAnimationFrame(frameId)
+ }, [rotationSpeed, rotationAngle])
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
@@ -4037,15 +4048,14 @@ export function MapRenderer({
}}
/>
)}
- {/* Enhanced SVG crosshair with heat effects - uses CSS animation for smooth rotation */}
-
+
{/* Fire particles around crosshair */}
{crosshairHeatStyle.showFire && (
@@ -4241,15 +4251,14 @@ export function MapRenderer({
}}
/>
)}
- {/* Enhanced SVG crosshair with heat effects - uses CSS animation */}
-
+
{/* Fire particles around crosshair */}
{heatStyle.showFire && (
handles translation (follows cursor) */}
- {/* Inner handles rotation via CSS animation */}
+ {/* Inner animated.g handles rotation via spring-driven animation */}
- `rotate(${a}deg)`),
transformOrigin: '0 0',
}}
>
@@ -4681,7 +4689,7 @@ export function MapRenderer({
vectorEffect="non-scaling-stroke"
opacity={heatStyle.opacity}
/>
-
+
{/* Fire particles around crosshair when on_fire or found_it */}
{heatStyle.showFire && (