From e055d43724b4eff4129ac76e5e207f17ee4023d5 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 00:57:02 +0300 Subject: [PATCH 1/9] moved chess logic to a separate custom hook --- apps/client/src/hooks/useChessGame.ts | 127 +++++++++++++++++++++++++ apps/client/src/pages/playerGame.tsx | 130 +------------------------- 2 files changed, 130 insertions(+), 127 deletions(-) create mode 100644 apps/client/src/hooks/useChessGame.ts diff --git a/apps/client/src/hooks/useChessGame.ts b/apps/client/src/hooks/useChessGame.ts new file mode 100644 index 0000000..bf10a63 --- /dev/null +++ b/apps/client/src/hooks/useChessGame.ts @@ -0,0 +1,127 @@ +import type { + GameMoveAck, + GameStateEvent, + GameSync, + MoveMadeEvent, + PlayerColor, + PlayerJoinedEvent, +} from "@chesslab/shared/types"; +import { Chess } from "chess.js"; +import { useEffect, useState } from "react"; +import { useSocket } from "./useSocket"; +import type { PieceDropHandlerArgs } from "react-chessboard"; + +export function useChessGame(gameId?: string) { + const { socket } = useSocket(); + + const [chessGame] = useState(new Chess()); + const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); + const [gameOverInfo, setGameOverInfo] = useState(); + const [opponentId, setOpponentId] = useState(); + const [color, setColor] = useState(); + const [, setTurn] = useState(); + const [isGameStarted, setGameStarted] = useState(false); + + useEffect(() => { + if (!gameId) return; + function handleMove({ fen, turn }: MoveMadeEvent) { + chessGame.load(fen); + setChessPosition(fen); + setTurn(turn); + } + + function handleGameOver(gameOverInfo: GameStateEvent) { + setGameOverInfo(gameOverInfo); + } + + function handleOpponentJoined({ opponentId }: PlayerJoinedEvent) { + setOpponentId(opponentId); + } + + function handleGameStarted() { + setGameStarted(true); + } + + socket.on("game:game-over", handleGameOver); + socket.on("game:move-made", handleMove); + socket.on("game:game-started", handleGameStarted); + socket.on("game:player-joined", handleOpponentJoined); + socket.emit("game:sync", (res: GameSync) => { + if (!res.ok) { + console.error("game:sync failed:", res.error); + return; + } + setColor(res.color); + setOpponentId(res.opponentId); + chessGame.load(res.fen); + setChessPosition(res.fen); + setTurn(res.turn); + if (res.opponentId) { + setGameStarted(true); + } + }); + + return () => { + socket.off("game:move-made", handleMove); + socket.off("game:game-over", handleGameOver); + socket.off("game:game-started", handleGameStarted); + socket.off("game:player-joined", handleOpponentJoined); + }; + }, [socket, chessGame, gameId]); + + function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { + if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted || !color) { + return false; + } + + const chess = chessGame; + + // Local turn check — avoid emitting when it's not our turn + if (chess.turn() !== color) { + return false; + } + + const previousFen = chess.fen(); + + try { + const move = chess.move({ + from: sourceSquare, + to: targetSquare, + promotion: "q", + }); + if (!move) return false; + } catch { + // chess.js throws on illegal moves + return false; + } + + // Optimistic UI: show the move immediately + setChessPosition(chess.fen()); + setTurn(chess.turn()); + + socket.emit( + "game:move", + { + promotion: "q", + from: sourceSquare, + to: targetSquare, + }, + (data: GameMoveAck) => { + if (data.ok) { + chessGame.load(data.fen); + setChessPosition(data.fen); + setTurn(data.turn); + } else { + chess.load(previousFen); + setChessPosition(previousFen); + setTurn(chess.turn()); + console.error("game:move rejected:", data.error); + } + }, + ); + + return true; + } + + return { onPieceDrop, color, chessPosition, opponentId, gameOverInfo }; +} diff --git a/apps/client/src/pages/playerGame.tsx b/apps/client/src/pages/playerGame.tsx index 4c03e17..7561e3d 100644 --- a/apps/client/src/pages/playerGame.tsx +++ b/apps/client/src/pages/playerGame.tsx @@ -1,140 +1,16 @@ -import { type ChessboardOptions, type PieceDropHandlerArgs } from "react-chessboard"; -import { Chess } from "chess.js"; -import { useEffect, useState } from "react"; +import { type ChessboardOptions } from "react-chessboard"; import { useParams } from "react-router"; -import type { - GameStateEvent, - GameMoveAck, - MoveMadeEvent, - PlayerJoinedEvent, - PlayerColor, - GameSync, -} from "@chesslab/shared/types"; import ChessBoard from "@components/chessBoard"; import SideBar from "@components/chessSidebar"; -import { useSocket } from "@hooks/useSocket"; +import { useChessGame } from "@hooks/useChessGame"; export default function PlayerGame() { const { roomId: gameId } = useParams(); - const { socket } = useSocket(); - - const [chessGame] = useState(new Chess()); - const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); - const [gameOverInfo, setGameOverInfo] = useState(); - const [opponentId, setOpponentId] = useState(); - const [color, setColor] = useState(); - const [, setTurn] = useState(); - const [isGameStarted, setGameStarted] = useState(false); - - useEffect(() => { - if (!gameId) return; - - function handleMove({ fen, turn }: MoveMadeEvent) { - chessGame.load(fen); - setChessPosition(fen); - setTurn(turn); - } - - function handleGameOver(gameOverInfo: GameStateEvent) { - setGameOverInfo(gameOverInfo); - } - - function handleOpponentJoined({ opponentId }: PlayerJoinedEvent) { - setOpponentId(opponentId); - } - - function handleGameStarted() { - setGameStarted(true); - } - - socket.on("game:game-over", handleGameOver); - socket.on("game:game-move", handleMove); - socket.on("game:move-made", handleMove); - socket.on("game:game-started", handleGameStarted); - socket.on("game:player-joined", handleOpponentJoined); - socket.emit("game:sync", (res: GameSync) => { - if (!res.ok) { - console.error("game:sync failed:", res.error); - return; - } - setColor(res.color); - setOpponentId(res.opponentId); - chessGame.load(res.fen); - setChessPosition(res.fen); - setTurn(res.turn); - if (res.opponentId) { - setGameStarted(true); - } - }); - - return () => { - socket.off("game:move-made", handleMove); - socket.off("game:game-move", handleMove); - socket.off("game:game-over", handleGameOver); - socket.off("game:game-started", handleGameStarted); - socket.off("game:player-joined", handleOpponentJoined); - }; - }, [gameId, socket, chessGame]); - - function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { - if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted || !color) { - return false; - } - - const chess = chessGame; - - // Local turn check — avoid emitting when it's not our turn - if (chess.turn() !== color) { - return false; - } - - const previousFen = chess.fen(); - - try { - const move = chess.move({ - from: sourceSquare, - to: targetSquare, - promotion: "q", - }); - if (!move) return false; - } catch { - // chess.js throws on illegal moves - return false; - } - - // Optimistic UI: show the move immediately - setChessPosition(chess.fen()); - setTurn(chess.turn()); - - socket.emit( - "game:move", - { - promotion: "q", - from: sourceSquare, - to: targetSquare, - }, - (data: GameMoveAck) => { - if (data.ok) { - chessGame.load(data.fen); - setChessPosition(data.fen); - setTurn(data.turn); - } else { - chess.load(previousFen); - setChessPosition(previousFen); - setTurn(chess.turn()); - console.error("game:move rejected:", data.error); - } - }, - ); - - return true; - } + const { onPieceDrop, chessPosition, color, opponentId, gameOverInfo } = useChessGame(gameId); const chessboardOptions: ChessboardOptions = { position: chessPosition, onPieceDrop, - // react-chessboard uses `#${id}-square-…` in querySelector; CSS ids - // cannot start with a digit, so raw UUIDs (e.g. 94632a9e-…) throw. id: gameId ? `board-${gameId}` : "board", boardOrientation: color === "w" ? "white" : "black", }; From e403a34efb1d830a707c09321086262d8b96cf22 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 19:17:35 +0300 Subject: [PATCH 2/9] Implemented fully working timer system on the client for multiplayer --- apps/client/src/components/Timer.tsx | 12 ++----- apps/client/src/hooks/useChessGame.ts | 33 ++++++++++++++++--- apps/client/src/hooks/useTimer.ts | 44 ++++++++++++++------------ apps/client/src/pages/computerGame.tsx | 17 +++++----- apps/client/src/pages/playerGame.tsx | 32 ++++++++++++------- apps/server/src/game/game.ts | 8 +++++ apps/server/src/sockets/socket.ts | 9 ++++-- packages/shared/src/types.ts | 23 +++++++------- 8 files changed, 110 insertions(+), 68 deletions(-) diff --git a/apps/client/src/components/Timer.tsx b/apps/client/src/components/Timer.tsx index cb29802..31efeea 100644 --- a/apps/client/src/components/Timer.tsx +++ b/apps/client/src/components/Timer.tsx @@ -2,21 +2,13 @@ import { cn } from "@utils/cn"; interface TimerType { side: "w" | "b"; - whiteDisplayTime: number; - blackDisplayTime: number; + displayTime: number; currentTurn: "w" | "b"; playerName: string | undefined; } -export function Timer({ - side, - blackDisplayTime, - whiteDisplayTime, - currentTurn, - playerName = "Random Player", -}: TimerType) { +export function Timer({ side, displayTime, currentTurn, playerName = "Random Player" }: TimerType) { const isActive = side === currentTurn; - const displayTime = side === "w" ? whiteDisplayTime : blackDisplayTime; return (
diff --git a/apps/client/src/hooks/useChessGame.ts b/apps/client/src/hooks/useChessGame.ts index bf10a63..e59b07c 100644 --- a/apps/client/src/hooks/useChessGame.ts +++ b/apps/client/src/hooks/useChessGame.ts @@ -1,15 +1,18 @@ import type { GameMoveAck, + GameStartedEvent, GameStateEvent, GameSync, MoveMadeEvent, PlayerColor, PlayerJoinedEvent, + TimeInfo, } from "@chesslab/shared/types"; import { Chess } from "chess.js"; import { useEffect, useState } from "react"; import { useSocket } from "./useSocket"; import type { PieceDropHandlerArgs } from "react-chessboard"; +import useTimer from "./useTimer"; export function useChessGame(gameId?: string) { const { socket } = useSocket(); @@ -18,16 +21,24 @@ export function useChessGame(gameId?: string) { const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); const [gameOverInfo, setGameOverInfo] = useState(); const [opponentId, setOpponentId] = useState(); - const [color, setColor] = useState(); - const [, setTurn] = useState(); + const [color, setColor] = useState("w"); + const [turn, setTurn] = useState("w"); const [isGameStarted, setGameStarted] = useState(false); + const [timeInfo, setTimeInfo] = useState(); + + const { whiteTimeMs, blackTimeMs } = useTimer({ + turn, + gameOverInfo, + timeInfo, + }); useEffect(() => { if (!gameId) return; - function handleMove({ fen, turn }: MoveMadeEvent) { + function handleMove({ fen, turn, timeInfo }: MoveMadeEvent) { chessGame.load(fen); setChessPosition(fen); setTurn(turn); + setTimeInfo(timeInfo); } function handleGameOver(gameOverInfo: GameStateEvent) { @@ -38,8 +49,9 @@ export function useChessGame(gameId?: string) { setOpponentId(opponentId); } - function handleGameStarted() { + function handleGameStarted({ timeInfo }: GameStartedEvent) { setGameStarted(true); + setTimeInfo(timeInfo); } socket.on("game:game-over", handleGameOver); @@ -51,11 +63,13 @@ export function useChessGame(gameId?: string) { console.error("game:sync failed:", res.error); return; } + console.log(res); setColor(res.color); setOpponentId(res.opponentId); chessGame.load(res.fen); setChessPosition(res.fen); setTurn(res.turn); + setTimeInfo(res.timeInfo); if (res.opponentId) { setGameStarted(true); } @@ -123,5 +137,14 @@ export function useChessGame(gameId?: string) { return true; } - return { onPieceDrop, color, chessPosition, opponentId, gameOverInfo }; + return { + onPieceDrop, + color, + chessPosition, + opponentId, + gameOverInfo, + whiteTimeMs, + blackTimeMs, + turn, + }; } diff --git a/apps/client/src/hooks/useTimer.ts b/apps/client/src/hooks/useTimer.ts index 0cbe41e..654d8f3 100644 --- a/apps/client/src/hooks/useTimer.ts +++ b/apps/client/src/hooks/useTimer.ts @@ -1,34 +1,38 @@ -import type { GameOverInfo } from "@chesslab/shared/types"; +import type { GameStateEvent, TimeInfo } from "@chesslab/shared/types"; import { useEffect, useState } from "react"; +export const START_TIME_MS = 60 * 10 * 1000; // 10 minutes, 600_000 ms export default function useTimer({ turn, gameOverInfo, + timeInfo, }: { turn: "w" | "b"; - gameOverInfo: GameOverInfo | undefined; + gameOverInfo: GameStateEvent | undefined; + timeInfo: TimeInfo | undefined; }) { - const [whiteDisplayTime, setWhiteDisplayTime] = useState(600_000); - const [blackDisplayTime, setBlackDisplayTime] = useState(600_000); + const [whiteTimeMs, setWhiteTimeMs] = useState(START_TIME_MS); + const [blackTimeMs, setBlackTimeMs] = useState(START_TIME_MS); useEffect(() => { - if (gameOverInfo) return; - let interval: NodeJS.Timeout | null = null; + if (gameOverInfo || !timeInfo) return; - if (turn === "w") { - interval = setInterval(() => { - setWhiteDisplayTime((prev) => Math.max(0, prev - 1000)); - }, 1000); - } else { - interval = setInterval(() => { - setBlackDisplayTime((prev) => Math.max(0, prev - 1000)); - }, 1000); - } + const interval = setInterval(() => { + const elapsed = timeInfo.lastMoveTime != null ? Date.now() - timeInfo.lastMoveTime : 0; - return () => { - if (interval) clearInterval(interval); - }; - }, [turn, gameOverInfo]); + setWhiteTimeMs( + turn === "w" ? Math.max(0, timeInfo.whiteTimeMs - elapsed) : timeInfo.whiteTimeMs, + ); + setBlackTimeMs( + turn === "b" ? Math.max(0, timeInfo.blackTimeMs - elapsed) : timeInfo.blackTimeMs, + ); + }, 250); - return { whiteDisplayTime, blackDisplayTime }; + return () => clearInterval(interval); + }, [turn, gameOverInfo, timeInfo]); + + return { + whiteTimeMs, + blackTimeMs, + }; } diff --git a/apps/client/src/pages/computerGame.tsx b/apps/client/src/pages/computerGame.tsx index 003e6b6..dfee19e 100644 --- a/apps/client/src/pages/computerGame.tsx +++ b/apps/client/src/pages/computerGame.tsx @@ -6,7 +6,7 @@ import useStockfish from "@hooks/useStockfish"; import useTimer from "@hooks/useTimer"; import { Timer } from "@components/Timer"; //import SideBar from "@components/chessSidebar"; -import type { GameOverInfo } from "@chesslab/shared/types"; +import type { GameStateEvent } from "@chesslab/shared/types"; export default function ComputerChessBoard() { const [chessGame] = useState(() => new Chess()); @@ -86,23 +86,22 @@ export default function ComputerChessBoard() { ); } -function getGameOverInfo(chess: Chess): GameOverInfo | undefined { +function getGameOverInfo(chess: Chess): GameStateEvent | undefined { if (!chess.isGameOver()) return undefined; if (chess.isCheckmate()) { return { + gameOver: true, reason: "Checkmate", - winner: chess.turn() === "w" ? "b" : "w", + winnerColor: chess.turn() === "w" ? "b" : "w", }; } else if (chess.isStalemate()) { - return { reason: "Stalemate", winner: "d" }; + return { gameOver: true, reason: "Stalemate", winnerColor: "d" }; } else if (chess.isInsufficientMaterial()) { - return { reason: "Insufficient Material", winner: "d" }; + return { gameOver: true, reason: "Insufficient Material", winnerColor: "d" }; } else if (chess.isThreefoldRepetition()) { - return { reason: "Threefold Repetition", winner: "d" }; + return { gameOver: true, reason: "Threefold Repetition", winnerColor: "d" }; } else if (chess.isDrawByFiftyMoves()) { - return { reason: "Fifty-Move Rule", winner: "d" }; - } else { - return { reason: "Draw", winner: "d" }; + return { gameOver: true, reason: "Fifty-Move Rule", winnerColor: "d" }; } } diff --git a/apps/client/src/pages/playerGame.tsx b/apps/client/src/pages/playerGame.tsx index 7561e3d..d7fbb9a 100644 --- a/apps/client/src/pages/playerGame.tsx +++ b/apps/client/src/pages/playerGame.tsx @@ -3,10 +3,22 @@ import { useParams } from "react-router"; import ChessBoard from "@components/chessBoard"; import SideBar from "@components/chessSidebar"; import { useChessGame } from "@hooks/useChessGame"; +import { Timer } from "@/components/Timer"; +import useUser from "@/hooks/useUser"; export default function PlayerGame() { const { roomId: gameId } = useParams(); - const { onPieceDrop, chessPosition, color, opponentId, gameOverInfo } = useChessGame(gameId); + const { + onPieceDrop, + chessPosition, + color, + opponentId, + gameOverInfo, + blackTimeMs, + whiteTimeMs, + turn, + } = useChessGame(gameId); + const { user } = useUser(); const chessboardOptions: ChessboardOptions = { position: chessPosition, @@ -18,21 +30,19 @@ export default function PlayerGame() { return ( <>
- {/**/} + /> - {/**/} + />
diff --git a/apps/server/src/game/game.ts b/apps/server/src/game/game.ts index 912f52d..a3417f3 100644 --- a/apps/server/src/game/game.ts +++ b/apps/server/src/game/game.ts @@ -186,6 +186,14 @@ export class Game extends EventEmitter { return this.blackPlayerId; } + getTimeInfo() { + return { + whiteTimeMs: this.whiteTimeMs, + blackTimeMs: this.blackTimeMs, + lastMoveTime: this.lastMoveTime, + }; + } + resign(playerId: string) { if (!this.isValidPlayerId(playerId)) throw new Error("Invalid Player Id"); diff --git a/apps/server/src/sockets/socket.ts b/apps/server/src/sockets/socket.ts index d9aee5c..bb76092 100644 --- a/apps/server/src/sockets/socket.ts +++ b/apps/server/src/sockets/socket.ts @@ -10,6 +10,7 @@ import type { PlayerJoinedEvent, GameSync, GameHistoryAck, + GameStartedEvent, } from "@chesslab/shared/types"; import { toErrorMessage } from "@chesslab/shared/errors"; import { io } from "@/io.js"; @@ -138,7 +139,9 @@ io.on("connection", (socket) => { } as PlayerJoinedEvent); game.start(); - io.to(gameId).emit("game:game-started"); + io.to(gameId).emit("game:game-started", { + timeInfo: game.getTimeInfo(), + } satisfies GameStartedEvent); cb({ ok: true, @@ -167,8 +170,9 @@ io.on("connection", (socket) => { game.move(userId, from, to, promotion); const fen = game.getFEN(); const turn = game.getTurn(); + const timeInfo = game.getTimeInfo(); - socket.to(gameId).emit("game:move-made", { fen, turn } as MoveMadeEvent); + socket.to(gameId).emit("game:move-made", { fen, turn, timeInfo } as MoveMadeEvent); if (game.isGameOver()) { io.to(gameId).emit("game:game-over", game.getGameStateEvent()); @@ -276,6 +280,7 @@ io.on("connection", (socket) => { opponentId: game.getMyOpponent(userId), fen: game.getFEN(), turn: game.getTurn(), + timeInfo: game.getTimeInfo(), }); } catch (err) { cb({ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8c54483..1c13418 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -22,17 +22,6 @@ export type JoinGameAck = gameId?: string; }; -export type GameOverInfo = { - reason: - | "Checkmate" - | "Stalemate" - | "Insufficient Material" - | "Threefold Repetition" - | "Fifty-Move Rule" - | "Draw"; - winner: "w" | "b" | "d"; -}; - export interface GameStateEvent { gameOver: boolean; reason: @@ -57,9 +46,20 @@ export type PlayerJoinedEvent = { opponentId: string; }; +export type TimeInfo = { + whiteTimeMs: number; + blackTimeMs: number; + lastMoveTime: number | undefined; +}; + export type MoveMadeEvent = { fen: string; turn: PlayerColor; + timeInfo: TimeInfo; +}; + +export type GameStartedEvent = { + timeInfo: TimeInfo; }; export type GameMoveAck = @@ -81,6 +81,7 @@ export type GameSync = opponentId: string | undefined; fen: string; turn: PlayerColor; + timeInfo: TimeInfo; } | { ok: false; From 5690b78cff4aabadfac2f78a2cadeb03ea851c9a Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 20:59:58 +0300 Subject: [PATCH 3/9] Implemented Timers for playing against the computer --- apps/client/src/components/Timer.tsx | 24 +++--- apps/client/src/hooks/useChessGame.ts | 12 ++- apps/client/src/hooks/useCompTimer.ts | 25 ++++++ apps/client/src/hooks/useComputerGame.ts | 79 +++++++++++++++++++ apps/client/src/hooks/useStockfish.ts | 10 ++- apps/client/src/pages/computerGame.tsx | 97 ++---------------------- apps/client/src/pages/playerGame.tsx | 24 +----- apps/server/src/game/game.ts | 26 ++----- packages/shared/package.json | 4 + packages/shared/src/utils.ts | 25 ++++++ pnpm-lock.yaml | 23 +++++- 11 files changed, 196 insertions(+), 153 deletions(-) create mode 100644 apps/client/src/hooks/useCompTimer.ts create mode 100644 apps/client/src/hooks/useComputerGame.ts create mode 100644 packages/shared/src/utils.ts diff --git a/apps/client/src/components/Timer.tsx b/apps/client/src/components/Timer.tsx index 31efeea..246f7b9 100644 --- a/apps/client/src/components/Timer.tsx +++ b/apps/client/src/components/Timer.tsx @@ -2,7 +2,7 @@ import { cn } from "@utils/cn"; interface TimerType { side: "w" | "b"; - displayTime: number; + displayTime: number | undefined; currentTurn: "w" | "b"; playerName: string | undefined; } @@ -16,16 +16,18 @@ export function Timer({ side, displayTime, currentTurn, playerName = "Random Pla {playerName}
-
- - {formatTime(displayTime)} - -
+ {displayTime !== undefined && ( +
+ + {formatTime(displayTime)} + +
+ )}
); } diff --git a/apps/client/src/hooks/useChessGame.ts b/apps/client/src/hooks/useChessGame.ts index e59b07c..b02bf8e 100644 --- a/apps/client/src/hooks/useChessGame.ts +++ b/apps/client/src/hooks/useChessGame.ts @@ -11,7 +11,7 @@ import type { import { Chess } from "chess.js"; import { useEffect, useState } from "react"; import { useSocket } from "./useSocket"; -import type { PieceDropHandlerArgs } from "react-chessboard"; +import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; import useTimer from "./useTimer"; export function useChessGame(gameId?: string) { @@ -137,14 +137,20 @@ export function useChessGame(gameId?: string) { return true; } - return { + const chessboardOptions: ChessboardOptions = { + position: chessPosition, onPieceDrop, + id: gameId ? `board-${gameId}` : "board", + boardOrientation: color === "w" ? "white" : "black", + }; + + return { color, - chessPosition, opponentId, gameOverInfo, whiteTimeMs, blackTimeMs, turn, + chessboardOptions, }; } diff --git a/apps/client/src/hooks/useCompTimer.ts b/apps/client/src/hooks/useCompTimer.ts new file mode 100644 index 0000000..50715c3 --- /dev/null +++ b/apps/client/src/hooks/useCompTimer.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +export const START_TIME_MS = 60 * 10 * 1000; // 10 minutes, 600_000 ms + +interface UseCompTimerProps { + turn: "w" | "b"; + side: "w" | "b"; + isGameOver: boolean; +} + +export function useCompTimer({ turn, side, isGameOver }: UseCompTimerProps) { + const [timeMs, setTimeMs] = useState(START_TIME_MS); + useEffect(() => { + if (isGameOver) return; + const timer = setInterval(() => { + if (turn === side) { + setTimeMs((prev) => prev - 250); + } + }, 250); + + return () => clearInterval(timer); + }, [side, turn, isGameOver]); + + return { timeMs, isTimeUp: timeMs <= 0 }; +} diff --git a/apps/client/src/hooks/useComputerGame.ts b/apps/client/src/hooks/useComputerGame.ts new file mode 100644 index 0000000..453f4b6 --- /dev/null +++ b/apps/client/src/hooks/useComputerGame.ts @@ -0,0 +1,79 @@ +import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; +import useStockfish from "./useStockfish"; +import { Chess } from "chess.js"; +import { useState } from "react"; +import { getGameOverInfo } from "@chesslab/shared/utils"; +import { useCompTimer } from "./useCompTimer"; + +export function useComputerGame() { + const [chessGame] = useState(() => new Chess()); + const [chessPosition, setChessPosition] = useState(() => new Chess().fen()); + const [turn, setTurn] = useState<"w" | "b">("w"); + const [gameHistory, setGameHistory] = useState([]); + const [side] = useState<"w" | "b">(() => (Math.random() < 0.5 ? "w" : "b")); + + let gameOverInfo = getGameOverInfo(chessGame); + + const { timeMs, isTimeUp } = useCompTimer({ + turn, + side, + isGameOver: !!gameOverInfo, + }); + + if (isTimeUp && !gameOverInfo) { + gameOverInfo = { + gameOver: true, + winnerColor: turn === "w" ? "b" : "w", + reason: "Timeout", + }; + } + + useStockfish({ + chessGame, + turn, + stockfishSide: side === "w" ? "b" : "w", + isGameOver: !!gameOverInfo, + onMove: () => { + setChessPosition(chessGame.fen()); + setTurn(chessGame.turn()); + setGameHistory(chessGame.history()); + }, + }); + + function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { + if (turn !== side || !targetSquare || gameOverInfo) return false; + + try { + const move = chessGame.move({ + from: sourceSquare, + to: targetSquare, + promotion: "q", + }); + + if (!move) return false; + + setChessPosition(chessGame.fen()); + setTurn(chessGame.turn()); + setGameHistory(chessGame.history()); + + return true; + } catch { + return false; + } + } + + const chessboardOptions: ChessboardOptions = { + position: chessPosition, + onPieceDrop, + boardOrientation: side === "w" ? "white" : "black", + }; + + return { + turn, + side, + timeMs, + gameHistory, + gameOverInfo, + chessboardOptions, + }; +} diff --git a/apps/client/src/hooks/useStockfish.ts b/apps/client/src/hooks/useStockfish.ts index 7e7fa2b..aa2dd37 100644 --- a/apps/client/src/hooks/useStockfish.ts +++ b/apps/client/src/hooks/useStockfish.ts @@ -6,11 +6,13 @@ export default function useStockfish({ chessGame, turn, stockfishSide, + isGameOver, onMove, }: { chessGame: Chess; turn: "w" | "b"; stockfishSide: "w" | "b"; + isGameOver: boolean; onMove: () => void; }) { useEffect(() => { @@ -25,7 +27,7 @@ export default function useStockfish({ const bestMove = parts[1]; if (bestMove && bestMove !== "(none)") { try { - if (chessGame.isGameOver()) return; + if (isGameOver) return; chessGame.move({ from: bestMove.substring(0, 2), to: bestMove.substring(2, 4), @@ -43,12 +45,12 @@ export default function useStockfish({ stockfish.postMessage("stop"); stockfish.onmessage = null; }; - }, [chessGame, onMove]); + }, [chessGame, onMove, isGameOver]); useEffect(() => { - if (turn === stockfishSide && !chessGame.isGameOver()) { + if (turn === stockfishSide && !isGameOver) { stockfish.postMessage(`position fen ${chessGame.fen()}`); stockfish.postMessage("go depth 15"); } - }, [turn, chessGame, stockfishSide]); + }, [turn, chessGame, stockfishSide, isGameOver]); } diff --git a/apps/client/src/pages/computerGame.tsx b/apps/client/src/pages/computerGame.tsx index dfee19e..99f37d8 100644 --- a/apps/client/src/pages/computerGame.tsx +++ b/apps/client/src/pages/computerGame.tsx @@ -1,107 +1,22 @@ -import { type ChessboardOptions, type PieceDropHandlerArgs } from "react-chessboard"; -import { useState } from "react"; -import { Chess } from "chess.js"; import ChessBoard from "@components/chessBoard"; -import useStockfish from "@hooks/useStockfish"; -import useTimer from "@hooks/useTimer"; import { Timer } from "@components/Timer"; -//import SideBar from "@components/chessSidebar"; -import type { GameStateEvent } from "@chesslab/shared/types"; +import SideBar from "@components/chessSidebar"; +import { useComputerGame } from "@hooks/useComputerGame"; export default function ComputerChessBoard() { - const [chessGame] = useState(() => new Chess()); - const [chessPosition, setChessPosition] = useState(() => new Chess().fen()); - const [turn, setTurn] = useState<"w" | "b">("w"); - const [, setGameHistory] = useState([]); - const gameOverInfo = getGameOverInfo(chessGame); - const [side] = useState<"w" | "b">(() => (Math.random() < 0.5 ? "w" : "b")); - - const { whiteDisplayTime, blackDisplayTime } = useTimer({ - turn, - gameOverInfo, - }); - - useStockfish({ - chessGame, - turn, - stockfishSide: side === "w" ? "b" : "w", - onMove: () => { - setChessPosition(chessGame.fen()); - setTurn(chessGame.turn()); - setGameHistory(chessGame.history()); - }, - }); - - function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { - if (chessGame.isGameOver()) return false; - - if (!targetSquare) { - return false; - } - - try { - const move = chessGame.move({ - from: sourceSquare, - to: targetSquare, - promotion: "q", - }); - - if (!move) return false; - - setChessPosition(chessGame.fen()); - setTurn(chessGame.turn()); - setGameHistory(chessGame.history()); - - return true; - } catch { - return false; - } - } - - const chessboardOptions: ChessboardOptions = { - position: chessPosition!, - onPieceDrop, - boardOrientation: side === "w" ? "white" : "black", - }; + const { turn, side, timeMs, gameOverInfo, chessboardOptions } = useComputerGame(); return (
- - {/**/} + +
); } - -function getGameOverInfo(chess: Chess): GameStateEvent | undefined { - if (!chess.isGameOver()) return undefined; - - if (chess.isCheckmate()) { - return { - gameOver: true, - reason: "Checkmate", - winnerColor: chess.turn() === "w" ? "b" : "w", - }; - } else if (chess.isStalemate()) { - return { gameOver: true, reason: "Stalemate", winnerColor: "d" }; - } else if (chess.isInsufficientMaterial()) { - return { gameOver: true, reason: "Insufficient Material", winnerColor: "d" }; - } else if (chess.isThreefoldRepetition()) { - return { gameOver: true, reason: "Threefold Repetition", winnerColor: "d" }; - } else if (chess.isDrawByFiftyMoves()) { - return { gameOver: true, reason: "Fifty-Move Rule", winnerColor: "d" }; - } -} diff --git a/apps/client/src/pages/playerGame.tsx b/apps/client/src/pages/playerGame.tsx index d7fbb9a..0edcebb 100644 --- a/apps/client/src/pages/playerGame.tsx +++ b/apps/client/src/pages/playerGame.tsx @@ -1,32 +1,16 @@ -import { type ChessboardOptions } from "react-chessboard"; import { useParams } from "react-router"; import ChessBoard from "@components/chessBoard"; import SideBar from "@components/chessSidebar"; import { useChessGame } from "@hooks/useChessGame"; -import { Timer } from "@/components/Timer"; -import useUser from "@/hooks/useUser"; +import { Timer } from "@components/Timer"; +import useUser from "@hooks/useUser"; export default function PlayerGame() { const { roomId: gameId } = useParams(); - const { - onPieceDrop, - chessPosition, - color, - opponentId, - gameOverInfo, - blackTimeMs, - whiteTimeMs, - turn, - } = useChessGame(gameId); + const { color, opponentId, gameOverInfo, blackTimeMs, whiteTimeMs, turn, chessboardOptions } = + useChessGame(gameId); const { user } = useUser(); - const chessboardOptions: ChessboardOptions = { - position: chessPosition, - onPieceDrop, - id: gameId ? `board-${gameId}` : "board", - boardOrientation: color === "w" ? "white" : "black", - }; - return ( <>
diff --git a/apps/server/src/game/game.ts b/apps/server/src/game/game.ts index a3417f3..e62d019 100644 --- a/apps/server/src/game/game.ts +++ b/apps/server/src/game/game.ts @@ -1,4 +1,5 @@ import type { GameStateEvent, PromotionPiece } from "@chesslab/shared/types"; +import { getGameOverInfo } from "@chesslab/shared/utils"; import { Chess } from "chess.js"; import EventEmitter from "node:events"; @@ -137,7 +138,7 @@ export class Game extends EventEmitter { this.clearTurnTimers(); if (this.chess.isGameOver()) { - this.evaluateGameOverState(playerId); + this.evaluateGameOverState(this.chess); } else { this.scheduleTurnTimer(); } @@ -262,27 +263,12 @@ export class Game extends EventEmitter { return playerId === this.whitePlayerId || playerId === this.blackPlayerId; } - private evaluateGameOverState(playerId: string) { - let reason: GameStateEvent["reason"]; - let winnerColor: GameStateEvent["winnerColor"]; - if (this.chess.isCheckmate()) { - reason = "Checkmate"; - winnerColor = this.getColor(playerId); - } else if (this.chess.isDraw()) { - winnerColor = "d"; - if (this.chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; - else if (this.chess.isInsufficientMaterial()) reason = "Insufficient Material"; - else if (this.chess.isStalemate()) reason = "Stalemate"; - else if (this.chess.isThreefoldRepetition()) reason = "Threefold Repetition"; - } + private evaluateGameOverState(chess: Chess) { + const gameStateInfo = getGameOverInfo(chess); + if (!gameStateInfo) return; + this.GameStateEvent = gameStateInfo; this.clearDisconnectTimeouts(); - - this.GameStateEvent = { - gameOver: true, - reason, - winnerColor, - }; } private assertPlayerCanMove(playerId: string) { diff --git a/packages/shared/package.json b/packages/shared/package.json index 4199a2b..fe56a35 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,6 +14,10 @@ "license": "ISC", "type": "module", "devDependencies": { + "@types/chess.js": "^0.13.7", "typescript": "^6.0.2" + }, + "dependencies": { + "chess.js": "^1.4.0" } } diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 0000000..e3c24cb --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,25 @@ +import type { GameStateEvent } from "./types.js"; +import type { Chess } from "chess.js"; + +export function getGameOverInfo(chess: Chess): GameStateEvent | undefined { + if (!chess.isGameOver()) return undefined; + + let reason: GameStateEvent["reason"]; + let winnerColor: GameStateEvent["winnerColor"]; + if (chess.isCheckmate()) { + reason = "Checkmate"; + winnerColor = chess.turn() === "w" ? "b" : "w"; + } else if (chess.isDraw()) { + winnerColor = "d"; + if (chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; + else if (chess.isInsufficientMaterial()) reason = "Insufficient Material"; + else if (chess.isStalemate()) reason = "Stalemate"; + else if (chess.isThreefoldRepetition()) reason = "Threefold Repetition"; + } + + return { + gameOver: true, + reason, + winnerColor, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc79f32..4ed411b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,7 +100,7 @@ importers: version: 19.2.17 "@types/react-dom": specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + version: 19.2.4(@types/react@19.2.17) "@vitejs/plugin-react": specifier: ^6.0.3 version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -191,7 +191,14 @@ importers: version: 6.0.3 packages/shared: + dependencies: + chess.js: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: + "@types/chess.js": + specifier: ^0.13.7 + version: 0.13.7 typescript: specifier: ^6.0.2 version: 6.0.3 @@ -1591,6 +1598,12 @@ packages: integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==, } + "@types/chess.js@0.13.7": + resolution: + { + integrity: sha512-o9TeeBdFXelFJXOD9+6sT6bbpBvWG0ZbpOnlIyUNXBjewh7xoFS0+BifGc71T45D5E952io3i8dDsaN6hnLaxw==, + } + "@types/connect@3.4.38": resolution: { @@ -1683,10 +1696,10 @@ packages: integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==, } - "@types/react-dom@19.2.3": + "@types/react-dom@19.2.4": resolution: { - integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==, + integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==, } peerDependencies: "@types/react": ^19.2.0 @@ -5198,6 +5211,8 @@ snapshots: "@types/connect": 3.4.38 "@types/node": 24.13.3 + "@types/chess.js@0.13.7": {} + "@types/connect@3.4.38": dependencies: "@types/node": 24.13.3 @@ -5250,7 +5265,7 @@ snapshots: "@types/range-parser@1.2.7": {} - "@types/react-dom@19.2.3(@types/react@19.2.17)": + "@types/react-dom@19.2.4(@types/react@19.2.17)": dependencies: "@types/react": 19.2.17 From e123ec01d62c3a8596d362aefc3f367dc14435d2 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 21:08:56 +0300 Subject: [PATCH 4/9] Oragnize the hooks folder --- apps/client/src/components/profile/deleteAccountBtn.tsx | 2 +- apps/client/src/components/profile/logoutBtn.tsx | 2 +- apps/client/src/hooks/{ => auth}/useAuth.ts | 0 apps/client/src/hooks/{ => auth}/useAuthForm.ts | 2 +- apps/client/src/hooks/{ => game}/useChessGame.ts | 2 +- apps/client/src/hooks/{ => game}/useCompTimer.ts | 0 apps/client/src/hooks/{ => game}/useComputerGame.ts | 0 apps/client/src/hooks/{ => game}/useCreateGame.ts | 0 apps/client/src/hooks/{ => game}/useJoinGame.ts | 0 apps/client/src/hooks/{ => game}/useStockfish.ts | 0 apps/client/src/hooks/{ => game}/useTimer.ts | 0 apps/client/src/hooks/useApi.ts | 2 +- apps/client/src/hooks/useFetchUser.ts | 2 +- apps/client/src/layouts/NotLoggedIn.tsx | 2 +- apps/client/src/layouts/RequireAuth.tsx | 2 +- apps/client/src/layouts/SocketProvider.tsx | 2 +- apps/client/src/pages/computerGame.tsx | 2 +- apps/client/src/pages/homePage.tsx | 4 ++-- apps/client/src/pages/loginPage.tsx | 2 +- apps/client/src/pages/playerGame.tsx | 2 +- apps/client/src/pages/signupPage.tsx | 2 +- 21 files changed, 15 insertions(+), 15 deletions(-) rename apps/client/src/hooks/{ => auth}/useAuth.ts (100%) rename apps/client/src/hooks/{ => auth}/useAuthForm.ts (97%) rename apps/client/src/hooks/{ => game}/useChessGame.ts (98%) rename apps/client/src/hooks/{ => game}/useCompTimer.ts (100%) rename apps/client/src/hooks/{ => game}/useComputerGame.ts (100%) rename apps/client/src/hooks/{ => game}/useCreateGame.ts (100%) rename apps/client/src/hooks/{ => game}/useJoinGame.ts (100%) rename apps/client/src/hooks/{ => game}/useStockfish.ts (100%) rename apps/client/src/hooks/{ => game}/useTimer.ts (100%) diff --git a/apps/client/src/components/profile/deleteAccountBtn.tsx b/apps/client/src/components/profile/deleteAccountBtn.tsx index 031eab5..58fed4f 100644 --- a/apps/client/src/components/profile/deleteAccountBtn.tsx +++ b/apps/client/src/components/profile/deleteAccountBtn.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; import { useApi } from "@hooks/useApi"; import { useNavigate } from "react-router"; diff --git a/apps/client/src/components/profile/logoutBtn.tsx b/apps/client/src/components/profile/logoutBtn.tsx index f1e3302..ab9ef00 100644 --- a/apps/client/src/components/profile/logoutBtn.tsx +++ b/apps/client/src/components/profile/logoutBtn.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import { useApi } from "@hooks/useApi"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export function LogoutBtn() { const { logout } = useAuth(); diff --git a/apps/client/src/hooks/useAuth.ts b/apps/client/src/hooks/auth/useAuth.ts similarity index 100% rename from apps/client/src/hooks/useAuth.ts rename to apps/client/src/hooks/auth/useAuth.ts diff --git a/apps/client/src/hooks/useAuthForm.ts b/apps/client/src/hooks/auth/useAuthForm.ts similarity index 97% rename from apps/client/src/hooks/useAuthForm.ts rename to apps/client/src/hooks/auth/useAuthForm.ts index 0486196..b63e5e1 100644 --- a/apps/client/src/hooks/useAuthForm.ts +++ b/apps/client/src/hooks/auth/useAuthForm.ts @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import useAuth from "./useAuth"; -import { useApi } from "./useApi"; +import { useApi } from "@hooks/useApi"; import { useState } from "react"; import type { LoginResponse, RegisterResponse } from "@chesslab/shared/types"; import { toErrorMessage } from "@chesslab/shared/errors"; diff --git a/apps/client/src/hooks/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts similarity index 98% rename from apps/client/src/hooks/useChessGame.ts rename to apps/client/src/hooks/game/useChessGame.ts index b02bf8e..b627cc9 100644 --- a/apps/client/src/hooks/useChessGame.ts +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -10,7 +10,7 @@ import type { } from "@chesslab/shared/types"; import { Chess } from "chess.js"; import { useEffect, useState } from "react"; -import { useSocket } from "./useSocket"; +import { useSocket } from "../useSocket"; import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; import useTimer from "./useTimer"; diff --git a/apps/client/src/hooks/useCompTimer.ts b/apps/client/src/hooks/game/useCompTimer.ts similarity index 100% rename from apps/client/src/hooks/useCompTimer.ts rename to apps/client/src/hooks/game/useCompTimer.ts diff --git a/apps/client/src/hooks/useComputerGame.ts b/apps/client/src/hooks/game/useComputerGame.ts similarity index 100% rename from apps/client/src/hooks/useComputerGame.ts rename to apps/client/src/hooks/game/useComputerGame.ts diff --git a/apps/client/src/hooks/useCreateGame.ts b/apps/client/src/hooks/game/useCreateGame.ts similarity index 100% rename from apps/client/src/hooks/useCreateGame.ts rename to apps/client/src/hooks/game/useCreateGame.ts diff --git a/apps/client/src/hooks/useJoinGame.ts b/apps/client/src/hooks/game/useJoinGame.ts similarity index 100% rename from apps/client/src/hooks/useJoinGame.ts rename to apps/client/src/hooks/game/useJoinGame.ts diff --git a/apps/client/src/hooks/useStockfish.ts b/apps/client/src/hooks/game/useStockfish.ts similarity index 100% rename from apps/client/src/hooks/useStockfish.ts rename to apps/client/src/hooks/game/useStockfish.ts diff --git a/apps/client/src/hooks/useTimer.ts b/apps/client/src/hooks/game/useTimer.ts similarity index 100% rename from apps/client/src/hooks/useTimer.ts rename to apps/client/src/hooks/game/useTimer.ts diff --git a/apps/client/src/hooks/useApi.ts b/apps/client/src/hooks/useApi.ts index 10f651a..0cbddb9 100644 --- a/apps/client/src/hooks/useApi.ts +++ b/apps/client/src/hooks/useApi.ts @@ -1,5 +1,5 @@ import { SERVER_URL } from "@/config"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@/hooks/auth/useAuth"; export function useApi() { const { accessToken, refresh, logout } = useAuth(); diff --git a/apps/client/src/hooks/useFetchUser.ts b/apps/client/src/hooks/useFetchUser.ts index 7d21b8b..e6f4793 100644 --- a/apps/client/src/hooks/useFetchUser.ts +++ b/apps/client/src/hooks/useFetchUser.ts @@ -1,7 +1,7 @@ import type { User } from "@chesslab/shared/types"; import { useEffect, useState } from "react"; import { useApi } from "@hooks/useApi"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@/hooks/auth/useAuth"; export default function useFetchUser() { const [user, setUser] = useState(undefined); diff --git a/apps/client/src/layouts/NotLoggedIn.tsx b/apps/client/src/layouts/NotLoggedIn.tsx index f3cfc4a..a0498d5 100644 --- a/apps/client/src/layouts/NotLoggedIn.tsx +++ b/apps/client/src/layouts/NotLoggedIn.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, useLocation, type Location } from "react-router"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export default function NotLoggedIn() { const { accessToken, isInitializing } = useAuth(); diff --git a/apps/client/src/layouts/RequireAuth.tsx b/apps/client/src/layouts/RequireAuth.tsx index e9664a2..d733265 100644 --- a/apps/client/src/layouts/RequireAuth.tsx +++ b/apps/client/src/layouts/RequireAuth.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, useLocation } from "react-router"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export default function RequireAuth() { const { accessToken, isInitializing } = useAuth(); diff --git a/apps/client/src/layouts/SocketProvider.tsx b/apps/client/src/layouts/SocketProvider.tsx index 3eb6f24..cfa979e 100644 --- a/apps/client/src/layouts/SocketProvider.tsx +++ b/apps/client/src/layouts/SocketProvider.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type PropsWithChildren } from "react"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; import { io, type Socket } from "socket.io-client"; import { SERVER_URL } from "@/config"; diff --git a/apps/client/src/pages/computerGame.tsx b/apps/client/src/pages/computerGame.tsx index 99f37d8..aa80be9 100644 --- a/apps/client/src/pages/computerGame.tsx +++ b/apps/client/src/pages/computerGame.tsx @@ -1,7 +1,7 @@ import ChessBoard from "@components/chessBoard"; import { Timer } from "@components/Timer"; import SideBar from "@components/chessSidebar"; -import { useComputerGame } from "@hooks/useComputerGame"; +import { useComputerGame } from "@hooks/game/useComputerGame"; export default function ComputerChessBoard() { const { turn, side, timeMs, gameOverInfo, chessboardOptions } = useComputerGame(); diff --git a/apps/client/src/pages/homePage.tsx b/apps/client/src/pages/homePage.tsx index 18344e7..6cb13ae 100644 --- a/apps/client/src/pages/homePage.tsx +++ b/apps/client/src/pages/homePage.tsx @@ -2,8 +2,8 @@ import { useNavigate } from "react-router"; import { routes } from "@/routes"; import type React from "react"; import { useState } from "react"; -import useCreateGame from "@hooks/useCreateGame"; -import useJoinGame from "@hooks/useJoinGame"; +import useCreateGame from "@hooks/game/useCreateGame"; +import useJoinGame from "@hooks/game/useJoinGame"; import TrendingUpIcon from "@icons/TrendingUpIcon"; import RookIcon from "@icons/RookIcon"; import RocketIcon from "@icons/RocketIcon"; diff --git a/apps/client/src/pages/loginPage.tsx b/apps/client/src/pages/loginPage.tsx index 7fc7269..afd630f 100644 --- a/apps/client/src/pages/loginPage.tsx +++ b/apps/client/src/pages/loginPage.tsx @@ -1,5 +1,5 @@ import AuthLayout from "@layouts/AuthLayout"; -import useAuthForm from "@hooks/useAuthForm"; +import useAuthForm from "@hooks/auth/useAuthForm"; import { AuthFormContainer } from "@components/AuthFormContainer"; import { AuthFormItem } from "@components/AuthFormItem"; diff --git a/apps/client/src/pages/playerGame.tsx b/apps/client/src/pages/playerGame.tsx index 0edcebb..6b6e2b7 100644 --- a/apps/client/src/pages/playerGame.tsx +++ b/apps/client/src/pages/playerGame.tsx @@ -1,7 +1,7 @@ import { useParams } from "react-router"; import ChessBoard from "@components/chessBoard"; import SideBar from "@components/chessSidebar"; -import { useChessGame } from "@hooks/useChessGame"; +import { useChessGame } from "@hooks/game/useChessGame"; import { Timer } from "@components/Timer"; import useUser from "@hooks/useUser"; diff --git a/apps/client/src/pages/signupPage.tsx b/apps/client/src/pages/signupPage.tsx index c63489d..943ef97 100644 --- a/apps/client/src/pages/signupPage.tsx +++ b/apps/client/src/pages/signupPage.tsx @@ -1,5 +1,5 @@ import AuthLayout from "@layouts/AuthLayout"; -import useAuthForm from "@hooks/useAuthForm"; +import useAuthForm from "@hooks/auth/useAuthForm"; import { AuthFormContainer } from "@components/AuthFormContainer"; import { AuthFormItem } from "@components/AuthFormItem"; From 3c4234c74bb6339b8dfd7c10153c359f25fbd20c Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 22:38:58 +0300 Subject: [PATCH 5/9] Fixed computer timer stalling when the window is not focused. --- apps/client/src/hooks/game/useCompTimer.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/client/src/hooks/game/useCompTimer.ts b/apps/client/src/hooks/game/useCompTimer.ts index 50715c3..b5dd3ae 100644 --- a/apps/client/src/hooks/game/useCompTimer.ts +++ b/apps/client/src/hooks/game/useCompTimer.ts @@ -12,13 +12,18 @@ export function useCompTimer({ turn, side, isGameOver }: UseCompTimerProps) { const [timeMs, setTimeMs] = useState(START_TIME_MS); useEffect(() => { if (isGameOver) return; - const timer = setInterval(() => { - if (turn === side) { - setTimeMs((prev) => prev - 250); - } - }, 250); - return () => clearInterval(timer); + let previousTime = Date.now(); + + const interval = setInterval(() => { + const now = Date.now(); + const elapsed = now - previousTime; + previousTime = now; + + setTimeMs((prev) => Math.max(0, prev - elapsed)); + }, 100); + + return () => clearInterval(interval); }, [side, turn, isGameOver]); return { timeMs, isTimeUp: timeMs <= 0 }; From d456e62c5e3980eeb4c4a997201441e8d05885c2 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 22:57:45 +0300 Subject: [PATCH 6/9] fix game:move ack doesn't return the time info to the player making the move --- apps/client/src/hooks/game/useChessGame.ts | 27 +++++++++++----------- apps/server/src/sockets/socket.ts | 1 + eslint.config.js | 2 +- packages/shared/src/types.ts | 1 + 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/client/src/hooks/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts index b627cc9..4359bc5 100644 --- a/apps/client/src/hooks/game/useChessGame.ts +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -9,7 +9,7 @@ import type { TimeInfo, } from "@chesslab/shared/types"; import { Chess } from "chess.js"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useSocket } from "../useSocket"; import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; import useTimer from "./useTimer"; @@ -32,14 +32,18 @@ export function useChessGame(gameId?: string) { timeInfo, }); - useEffect(() => { - if (!gameId) return; - function handleMove({ fen, turn, timeInfo }: MoveMadeEvent) { + const handleMove = useCallback( + ({ fen, turn, timeInfo }: MoveMadeEvent) => { chessGame.load(fen); setChessPosition(fen); setTurn(turn); setTimeInfo(timeInfo); - } + }, + [chessGame], + ); + + useEffect(() => { + if (!gameId) return; function handleGameOver(gameOverInfo: GameStateEvent) { setGameOverInfo(gameOverInfo); @@ -63,13 +67,10 @@ export function useChessGame(gameId?: string) { console.error("game:sync failed:", res.error); return; } - console.log(res); setColor(res.color); setOpponentId(res.opponentId); - chessGame.load(res.fen); - setChessPosition(res.fen); - setTurn(res.turn); - setTimeInfo(res.timeInfo); + + handleMove(res); if (res.opponentId) { setGameStarted(true); } @@ -81,7 +82,7 @@ export function useChessGame(gameId?: string) { socket.off("game:game-started", handleGameStarted); socket.off("game:player-joined", handleOpponentJoined); }; - }, [socket, chessGame, gameId]); + }, [socket, chessGame, gameId, handleMove]); function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted || !color) { @@ -122,9 +123,7 @@ export function useChessGame(gameId?: string) { }, (data: GameMoveAck) => { if (data.ok) { - chessGame.load(data.fen); - setChessPosition(data.fen); - setTurn(data.turn); + handleMove(data); } else { chess.load(previousFen); setChessPosition(previousFen); diff --git a/apps/server/src/sockets/socket.ts b/apps/server/src/sockets/socket.ts index bb76092..92e1795 100644 --- a/apps/server/src/sockets/socket.ts +++ b/apps/server/src/sockets/socket.ts @@ -182,6 +182,7 @@ io.on("connection", (socket) => { ok: true, fen, turn, + timeInfo, }); } catch (err) { cb({ diff --git a/eslint.config.js b/eslint.config.js index 8d64fa9..31a5efd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ export default defineConfig([ extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs.flat.recommended, + reactHooks.configs.flat["recommended-latest"], reactRefresh.configs.vite, eslintConfigPrettier, ], diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1c13418..1332c7d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -67,6 +67,7 @@ export type GameMoveAck = ok: true; fen: string; turn: PlayerColor; + timeInfo: TimeInfo; } | { ok: false; From 9f62d8c2bc01ef6ca319b50f3f63bb567dddd4c5 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sat, 8 Aug 2026 23:03:41 +0300 Subject: [PATCH 7/9] small fixes --- apps/client/src/components/Timer.tsx | 2 +- apps/client/src/hooks/game/useChessGame.ts | 4 ++-- apps/client/src/pages/computerGame.tsx | 7 +------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/client/src/components/Timer.tsx b/apps/client/src/components/Timer.tsx index 246f7b9..a6dda77 100644 --- a/apps/client/src/components/Timer.tsx +++ b/apps/client/src/components/Timer.tsx @@ -2,7 +2,7 @@ import { cn } from "@utils/cn"; interface TimerType { side: "w" | "b"; - displayTime: number | undefined; + displayTime?: number; currentTurn: "w" | "b"; playerName: string | undefined; } diff --git a/apps/client/src/hooks/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts index 4359bc5..46fc9fb 100644 --- a/apps/client/src/hooks/game/useChessGame.ts +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -17,7 +17,7 @@ import useTimer from "./useTimer"; export function useChessGame(gameId?: string) { const { socket } = useSocket(); - const [chessGame] = useState(new Chess()); + const [chessGame] = useState(() => new Chess()); const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); const [gameOverInfo, setGameOverInfo] = useState(); const [opponentId, setOpponentId] = useState(); @@ -85,7 +85,7 @@ export function useChessGame(gameId?: string) { }, [socket, chessGame, gameId, handleMove]); function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { - if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted || !color) { + if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted) { return false; } diff --git a/apps/client/src/pages/computerGame.tsx b/apps/client/src/pages/computerGame.tsx index aa80be9..668c2a9 100644 --- a/apps/client/src/pages/computerGame.tsx +++ b/apps/client/src/pages/computerGame.tsx @@ -8,12 +8,7 @@ export default function ComputerChessBoard() { return (
- + From e48bb3cf379e8450c781b702e23035ccbc82a892 Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sun, 9 Aug 2026 00:10:44 +0300 Subject: [PATCH 8/9] multiple fixes --- apps/client/src/components/chessSidebar.tsx | 2 +- apps/client/src/hooks/game/useChessGame.ts | 21 ++++++++++++--------- apps/client/src/hooks/game/useCompTimer.ts | 2 +- apps/client/src/hooks/game/useTimer.ts | 2 +- apps/server/src/game/game.ts | 6 +++--- apps/server/src/sockets/socket.ts | 14 ++++++-------- packages/shared/package.json | 1 - packages/shared/src/utils.ts | 4 ++-- pnpm-lock.yaml | 11 ----------- 9 files changed, 26 insertions(+), 37 deletions(-) diff --git a/apps/client/src/components/chessSidebar.tsx b/apps/client/src/components/chessSidebar.tsx index 07a48a0..cb78cc2 100644 --- a/apps/client/src/components/chessSidebar.tsx +++ b/apps/client/src/components/chessSidebar.tsx @@ -8,7 +8,7 @@ export default function SideBar({ gameState: GameStateEvent | undefined; }) { return ( -
+
{opponent && (
diff --git a/apps/client/src/hooks/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts index 46fc9fb..4311605 100644 --- a/apps/client/src/hooks/game/useChessGame.ts +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -114,22 +114,25 @@ export function useChessGame(gameId?: string) { setChessPosition(chess.fen()); setTurn(chess.turn()); - socket.emit( + function fallbackMove() { + chess.load(previousFen); + setChessPosition(previousFen); + setTurn(chess.turn()); + } + + socket.timeout(2_000).emit( "game:move", { promotion: "q", from: sourceSquare, to: targetSquare, }, - (data: GameMoveAck) => { - if (data.ok) { - handleMove(data); - } else { - chess.load(previousFen); - setChessPosition(previousFen); - setTurn(chess.turn()); - console.error("game:move rejected:", data.error); + (err: unknown, data: GameMoveAck) => { + if (err || !data?.ok) { + fallbackMove(); + return; } + handleMove(data); }, ); diff --git a/apps/client/src/hooks/game/useCompTimer.ts b/apps/client/src/hooks/game/useCompTimer.ts index b5dd3ae..6435a6d 100644 --- a/apps/client/src/hooks/game/useCompTimer.ts +++ b/apps/client/src/hooks/game/useCompTimer.ts @@ -11,7 +11,7 @@ interface UseCompTimerProps { export function useCompTimer({ turn, side, isGameOver }: UseCompTimerProps) { const [timeMs, setTimeMs] = useState(START_TIME_MS); useEffect(() => { - if (isGameOver) return; + if (isGameOver || turn !== side) return; let previousTime = Date.now(); diff --git a/apps/client/src/hooks/game/useTimer.ts b/apps/client/src/hooks/game/useTimer.ts index 654d8f3..0607f52 100644 --- a/apps/client/src/hooks/game/useTimer.ts +++ b/apps/client/src/hooks/game/useTimer.ts @@ -15,7 +15,7 @@ export default function useTimer({ const [blackTimeMs, setBlackTimeMs] = useState(START_TIME_MS); useEffect(() => { - if (gameOverInfo || !timeInfo) return; + if (gameOverInfo?.gameOver || !timeInfo) return; const interval = setInterval(() => { const elapsed = timeInfo.lastMoveTime != null ? Date.now() - timeInfo.lastMoveTime : 0; diff --git a/apps/server/src/game/game.ts b/apps/server/src/game/game.ts index e62d019..3336421 100644 --- a/apps/server/src/game/game.ts +++ b/apps/server/src/game/game.ts @@ -138,7 +138,7 @@ export class Game extends EventEmitter { this.clearTurnTimers(); if (this.chess.isGameOver()) { - this.evaluateGameOverState(this.chess); + this.evaluateGameOverState(); } else { this.scheduleTurnTimer(); } @@ -263,8 +263,8 @@ export class Game extends EventEmitter { return playerId === this.whitePlayerId || playerId === this.blackPlayerId; } - private evaluateGameOverState(chess: Chess) { - const gameStateInfo = getGameOverInfo(chess); + private evaluateGameOverState() { + const gameStateInfo = getGameOverInfo(this.chess); if (!gameStateInfo) return; this.GameStateEvent = gameStateInfo; diff --git a/apps/server/src/sockets/socket.ts b/apps/server/src/sockets/socket.ts index 92e1795..b6226b5 100644 --- a/apps/server/src/sockets/socket.ts +++ b/apps/server/src/sockets/socket.ts @@ -84,9 +84,7 @@ io.on("connection", (socket) => { socket.data.gameInfo = { gameId, game }; game.on("game-over", ({ GameStateEvent }) => { - io.to(gameId).emit("game:game-over", { - GameStateEvent, - }); + io.to(gameId).emit("game:game-over", GameStateEvent); }); cb({ @@ -127,16 +125,14 @@ io.on("connection", (socket) => { socket.data.gameInfo = { gameId, game }; game.on("game-over", () => { - io.to(gameId).emit("game:game-over", { - GameStateEvent: game.getGameStateEvent(), - }); + io.to(gameId).emit("game:game-over", game.getGameStateEvent()); }); socket.to(gameId).emit("game:player-joined", { gameId, opponentColor: game.getColor(userId), opponentId: userId, - } as PlayerJoinedEvent); + } satisfies PlayerJoinedEvent); game.start(); io.to(gameId).emit("game:game-started", { @@ -172,7 +168,9 @@ io.on("connection", (socket) => { const turn = game.getTurn(); const timeInfo = game.getTimeInfo(); - socket.to(gameId).emit("game:move-made", { fen, turn, timeInfo } as MoveMadeEvent); + socket + .to(gameId) + .emit("game:move-made", { fen, turn, timeInfo } satisfies MoveMadeEvent); if (game.isGameOver()) { io.to(gameId).emit("game:game-over", game.getGameStateEvent()); diff --git a/packages/shared/package.json b/packages/shared/package.json index fe56a35..896e214 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,7 +14,6 @@ "license": "ISC", "type": "module", "devDependencies": { - "@types/chess.js": "^0.13.7", "typescript": "^6.0.2" }, "dependencies": { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index e3c24cb..56dffc6 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -11,10 +11,10 @@ export function getGameOverInfo(chess: Chess): GameStateEvent | undefined { winnerColor = chess.turn() === "w" ? "b" : "w"; } else if (chess.isDraw()) { winnerColor = "d"; - if (chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; + if (chess.isStalemate()) reason = "Stalemate"; else if (chess.isInsufficientMaterial()) reason = "Insufficient Material"; - else if (chess.isStalemate()) reason = "Stalemate"; else if (chess.isThreefoldRepetition()) reason = "Threefold Repetition"; + else if (chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; } return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ed411b..c91ad2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,9 +196,6 @@ importers: specifier: ^1.4.0 version: 1.4.0 devDependencies: - "@types/chess.js": - specifier: ^0.13.7 - version: 0.13.7 typescript: specifier: ^6.0.2 version: 6.0.3 @@ -1598,12 +1595,6 @@ packages: integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==, } - "@types/chess.js@0.13.7": - resolution: - { - integrity: sha512-o9TeeBdFXelFJXOD9+6sT6bbpBvWG0ZbpOnlIyUNXBjewh7xoFS0+BifGc71T45D5E952io3i8dDsaN6hnLaxw==, - } - "@types/connect@3.4.38": resolution: { @@ -5211,8 +5202,6 @@ snapshots: "@types/connect": 3.4.38 "@types/node": 24.13.3 - "@types/chess.js@0.13.7": {} - "@types/connect@3.4.38": dependencies: "@types/node": 24.13.3 From 8ffa38fcdcd78c83b759a955a20d8dace2196b4c Mon Sep 17 00:00:00 2001 From: Mohamed Date: Sun, 9 Aug 2026 00:30:12 +0300 Subject: [PATCH 9/9] Removed timeout on the game:move event --- apps/client/src/hooks/game/useChessGame.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/src/hooks/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts index 4311605..ad5aa5b 100644 --- a/apps/client/src/hooks/game/useChessGame.ts +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -120,15 +120,15 @@ export function useChessGame(gameId?: string) { setTurn(chess.turn()); } - socket.timeout(2_000).emit( + socket.emit( "game:move", { promotion: "q", from: sourceSquare, to: targetSquare, }, - (err: unknown, data: GameMoveAck) => { - if (err || !data?.ok) { + (data: GameMoveAck) => { + if (!data?.ok) { fallbackMove(); return; }