From bd014bec4ffa12bcd8f4a4e84ff51203c90c1f1d Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 25 Oct 2025 07:14:15 -0500 Subject: [PATCH] fix(card-sorting): prevent ghost movements with proper optimistic updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes ghost movements when dragging cards by properly tracking which position updates originated from this browser window/tab. Previously used a timing-based approach (debounce) which was brittle and didn't work reliably on slow connections. New approach: - Generate unique windowId for each browser tab - Include windowId in all position updates sent to server - Skip server position updates that contain our own windowId - This prevents replaying our own movements when they echo back The user should never see their own movements repeated since they already have those positions locally applied. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/.claude/settings.local.json | 7 +++-- .../components/PlayingPhaseDrag.tsx | 27 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 29c13446..999e2b58 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -110,11 +110,14 @@ "WebFetch(domain:www.npmjs.com)", "mcp__sqlite__list_tables", "mcp__sqlite__describe_table", - "mcp__sqlite__read_query" + "mcp__sqlite__read_query", + "Bash(git rebase:*)" ], "deny": [], "ask": [] }, "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": ["sqlite"] + "enabledMcpjsonServers": [ + "sqlite" + ] } diff --git a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx index 97cbf41a..d3812f74 100644 --- a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx @@ -916,8 +916,12 @@ export function PlayingPhaseDrag() { initialRotation: number } | null>(null) - // Track timestamp of last position update we sent to avoid re-applying our own updates - const lastPositionUpdateRef = useRef(0) + // Generate a stable unique ID for this browser window/tab + // This allows us to identify our own position updates when they echo back from the server + const windowIdRef = useRef() + if (!windowIdRef.current) { + windowIdRef.current = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + } // Track card positions and visual states (UI only - not game state) const [cardStates, setCardStates] = useState>(new Map()) @@ -1157,6 +1161,8 @@ export function PlayingPhaseDrag() { y: cardState.y, rotation: cardState.rotation, zIndex: cardState.zIndex, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, })) updateCardPositions(positions) } @@ -1175,11 +1181,12 @@ export function PlayingPhaseDrag() { if (!state.cardPositions || state.cardPositions.length === 0) return if (cardStates.size === 0) return - // Ignore server updates for 2000ms after we send our own update - // This prevents replaying our own movements when they bounce back from server - // Increased from 500ms to 2000ms to handle low bandwidth connections - const timeSinceOurUpdate = Date.now() - lastPositionUpdateRef.current - if (timeSinceOurUpdate < 2000) return + // Check if any updates originated from this window - if so, skip the entire batch + // This prevents replaying our own movements when they echo back from the server + const hasOurUpdates = state.cardPositions.some( + (pos) => pos.draggedByWindowId === windowIdRef.current + ) + if (hasOurUpdates) return // Check if server positions differ from current positions let needsUpdate = false @@ -1303,7 +1310,6 @@ export function PlayingPhaseDrag() { const now = Date.now() if (now - lastSyncTimeRef.current > 100) { lastSyncTimeRef.current = now - lastPositionUpdateRef.current = now const positions = Array.from(newStates.entries()).map(([id, state]) => ({ cardId: id, x: state.x, @@ -1312,6 +1318,8 @@ export function PlayingPhaseDrag() { zIndex: state.zIndex, // Mark this card as being dragged by local player draggedByPlayerId: id === cardId ? localPlayerId : undefined, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, })) updateCardPositions(positions) } @@ -1348,8 +1356,9 @@ export function PlayingPhaseDrag() { zIndex: state.zIndex, // Clear draggedByPlayerId when drag ends draggedByPlayerId: undefined, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, })) - lastPositionUpdateRef.current = Date.now() updateCardPositions(positions) } }