From 132f92d2d9e6c710c1f50d6f14bfca131f212175 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 2 Apr 2026 17:06:58 +0200 Subject: [PATCH] fix(chess): optimistic moves and forfeit UI feedback - Add localFen/localFenRef in ChessBoard for optimistic piece placement, preventing snap-back while awaiting server confirmation - Sync localFen from server state on each chess.fen update - Guard GAME_STATE handler in useGameRoom from overwriting a finished roomStatus, fixing the race where GAME_ENDED (pub/sub) arrives before GAME_STATE (direct ws.send) - Reset confirmForfeit immediately on forfeit dispatch for instant UI feedback Co-Authored-By: Claude Sonnet 4.6 --- panel/src/games/chess/ChessBoard.tsx | 35 ++++++++++++++++++++++------ panel/src/lib/useGameRoom.ts | 6 ++++- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/panel/src/games/chess/ChessBoard.tsx b/panel/src/games/chess/ChessBoard.tsx index 78c6a6b..7a39c92 100644 --- a/panel/src/games/chess/ChessBoard.tsx +++ b/panel/src/games/chess/ChessBoard.tsx @@ -22,6 +22,18 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } const lastMoveRef = useRef<{ from: string; to: string } | null>(null); const [boardWidth, setBoardWidth] = useState(400); + // Optimistic local FEN: updated immediately on valid moves, synced from server state + const [localFen, setLocalFen] = useState(() => chess?.fen ?? "start"); + const localFenRef = useRef(localFen); + + // Sync local FEN when server confirms a new state + useEffect(() => { + if (chess?.fen) { + localFenRef.current = chess.fen; + setLocalFen(chess.fen); + } + }, [chess?.fen]); + // Track latest state in ref to avoid stale closures const chessRef = useRef(chess); useEffect(() => { @@ -51,9 +63,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } }, [chess?.moveHistory?.length]); const game = useMemo(() => { - if (!chess?.fen) return null; - return new Chess(chess.fen); - }, [chess?.fen]); + if (!localFen) return null; + return new Chess(localFen); + }, [localFen]); if (!game) { return
Waiting for game to start...
; @@ -76,6 +88,15 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver; function dispatchMove(from: string, to: string, promotion?: string) { + // Optimistically apply the move locally so the board doesn't snap back + try { + const optimistic = new Chess(localFenRef.current); + const moved = optimistic.move({ from, to, promotion: promotion ?? "q" }); + if (moved) { + localFenRef.current = optimistic.fen(); + setLocalFen(optimistic.fen()); + } + } catch { /* invalid move — server will reject it */ } lastMoveRef.current = { from, to }; setSelectedSquare(null); onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) }); @@ -84,7 +105,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } function onDrop(sourceSquare: string, targetSquare: string): boolean { if (isSpectator || !isMyTurn || isGameOver) return false; - const testGame = new Chess(chessRef.current.fen); + const testGame = new Chess(localFenRef.current); // Check if this is a promotion move const piece = testGame.get(sourceSquare as any); @@ -128,7 +149,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } // If promotion dialog is open, ignore clicks if (promotionFrom !== null) return; - const testGame = new Chess(chessRef.current.fen); + const testGame = new Chess(localFenRef.current); // If a square is already selected if (selectedSquare !== null) { @@ -276,7 +297,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }