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/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts
new file mode 100644
index 0000000..ad5aa5b
--- /dev/null
+++ b/apps/client/src/hooks/game/useChessGame.ts
@@ -0,0 +1,158 @@
+import type {
+ GameMoveAck,
+ GameStartedEvent,
+ GameStateEvent,
+ GameSync,
+ MoveMadeEvent,
+ PlayerColor,
+ PlayerJoinedEvent,
+ TimeInfo,
+} from "@chesslab/shared/types";
+import { Chess } from "chess.js";
+import { useCallback, useEffect, useState } from "react";
+import { useSocket } from "../useSocket";
+import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard";
+import useTimer from "./useTimer";
+
+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("w");
+ const [turn, setTurn] = useState("w");
+ const [isGameStarted, setGameStarted] = useState(false);
+ const [timeInfo, setTimeInfo] = useState();
+
+ const { whiteTimeMs, blackTimeMs } = useTimer({
+ turn,
+ gameOverInfo,
+ timeInfo,
+ });
+
+ 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);
+ }
+
+ function handleOpponentJoined({ opponentId }: PlayerJoinedEvent) {
+ setOpponentId(opponentId);
+ }
+
+ function handleGameStarted({ timeInfo }: GameStartedEvent) {
+ setGameStarted(true);
+ setTimeInfo(timeInfo);
+ }
+
+ 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);
+
+ handleMove(res);
+ 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, handleMove]);
+
+ function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) {
+ if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted) {
+ 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());
+
+ function fallbackMove() {
+ chess.load(previousFen);
+ setChessPosition(previousFen);
+ setTurn(chess.turn());
+ }
+
+ socket.emit(
+ "game:move",
+ {
+ promotion: "q",
+ from: sourceSquare,
+ to: targetSquare,
+ },
+ (data: GameMoveAck) => {
+ if (!data?.ok) {
+ fallbackMove();
+ return;
+ }
+ handleMove(data);
+ },
+ );
+
+ return true;
+ }
+
+ const chessboardOptions: ChessboardOptions = {
+ position: chessPosition,
+ onPieceDrop,
+ id: gameId ? `board-${gameId}` : "board",
+ boardOrientation: color === "w" ? "white" : "black",
+ };
+
+ return {
+ color,
+ opponentId,
+ gameOverInfo,
+ whiteTimeMs,
+ blackTimeMs,
+ turn,
+ chessboardOptions,
+ };
+}
diff --git a/apps/client/src/hooks/game/useCompTimer.ts b/apps/client/src/hooks/game/useCompTimer.ts
new file mode 100644
index 0000000..6435a6d
--- /dev/null
+++ b/apps/client/src/hooks/game/useCompTimer.ts
@@ -0,0 +1,30 @@
+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 || turn !== side) return;
+
+ 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 };
+}
diff --git a/apps/client/src/hooks/game/useComputerGame.ts b/apps/client/src/hooks/game/useComputerGame.ts
new file mode 100644
index 0000000..453f4b6
--- /dev/null
+++ b/apps/client/src/hooks/game/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/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 85%
rename from apps/client/src/hooks/useStockfish.ts
rename to apps/client/src/hooks/game/useStockfish.ts
index 7e7fa2b..aa2dd37 100644
--- a/apps/client/src/hooks/useStockfish.ts
+++ b/apps/client/src/hooks/game/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/hooks/game/useTimer.ts b/apps/client/src/hooks/game/useTimer.ts
new file mode 100644
index 0000000..0607f52
--- /dev/null
+++ b/apps/client/src/hooks/game/useTimer.ts
@@ -0,0 +1,38 @@
+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: GameStateEvent | undefined;
+ timeInfo: TimeInfo | undefined;
+}) {
+ const [whiteTimeMs, setWhiteTimeMs] = useState(START_TIME_MS);
+ const [blackTimeMs, setBlackTimeMs] = useState(START_TIME_MS);
+
+ useEffect(() => {
+ if (gameOverInfo?.gameOver || !timeInfo) return;
+
+ const interval = setInterval(() => {
+ const elapsed = timeInfo.lastMoveTime != null ? Date.now() - timeInfo.lastMoveTime : 0;
+
+ setWhiteTimeMs(
+ turn === "w" ? Math.max(0, timeInfo.whiteTimeMs - elapsed) : timeInfo.whiteTimeMs,
+ );
+ setBlackTimeMs(
+ turn === "b" ? Math.max(0, timeInfo.blackTimeMs - elapsed) : timeInfo.blackTimeMs,
+ );
+ }, 250);
+
+ return () => clearInterval(interval);
+ }, [turn, gameOverInfo, timeInfo]);
+
+ return {
+ whiteTimeMs,
+ blackTimeMs,
+ };
+}
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/hooks/useTimer.ts b/apps/client/src/hooks/useTimer.ts
deleted file mode 100644
index 0cbe41e..0000000
--- a/apps/client/src/hooks/useTimer.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { GameOverInfo } from "@chesslab/shared/types";
-import { useEffect, useState } from "react";
-
-export default function useTimer({
- turn,
- gameOverInfo,
-}: {
- turn: "w" | "b";
- gameOverInfo: GameOverInfo | undefined;
-}) {
- const [whiteDisplayTime, setWhiteDisplayTime] = useState(600_000);
- const [blackDisplayTime, setBlackDisplayTime] = useState(600_000);
-
- useEffect(() => {
- if (gameOverInfo) return;
- let interval: NodeJS.Timeout | null = null;
-
- if (turn === "w") {
- interval = setInterval(() => {
- setWhiteDisplayTime((prev) => Math.max(0, prev - 1000));
- }, 1000);
- } else {
- interval = setInterval(() => {
- setBlackDisplayTime((prev) => Math.max(0, prev - 1000));
- }, 1000);
- }
-
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [turn, gameOverInfo]);
-
- return { whiteDisplayTime, blackDisplayTime };
-}
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 003e6b6..668c2a9 100644
--- a/apps/client/src/pages/computerGame.tsx
+++ b/apps/client/src/pages/computerGame.tsx
@@ -1,108 +1,17 @@
-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 { GameOverInfo } from "@chesslab/shared/types";
+import SideBar from "@components/chessSidebar";
+import { useComputerGame } from "@hooks/game/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): GameOverInfo | undefined {
- if (!chess.isGameOver()) return undefined;
-
- if (chess.isCheckmate()) {
- return {
- reason: "Checkmate",
- winner: chess.turn() === "w" ? "b" : "w",
- };
- } else if (chess.isStalemate()) {
- return { reason: "Stalemate", winner: "d" };
- } else if (chess.isInsufficientMaterial()) {
- return { reason: "Insufficient Material", winner: "d" };
- } else if (chess.isThreefoldRepetition()) {
- return { reason: "Threefold Repetition", winner: "d" };
- } else if (chess.isDrawByFiftyMoves()) {
- return { reason: "Fifty-Move Rule", winner: "d" };
- } else {
- return { reason: "Draw", winner: "d" };
- }
-}
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 4c03e17..6b6e2b7 100644
--- a/apps/client/src/pages/playerGame.tsx
+++ b/apps/client/src/pages/playerGame.tsx
@@ -1,162 +1,32 @@
-import { type ChessboardOptions, type PieceDropHandlerArgs } from "react-chessboard";
-import { Chess } from "chess.js";
-import { useEffect, useState } from "react";
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/game/useChessGame";
+import { Timer } from "@components/Timer";
+import useUser from "@hooks/useUser";
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 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",
- };
+ const { color, opponentId, gameOverInfo, blackTimeMs, whiteTimeMs, turn, chessboardOptions } =
+ useChessGame(gameId);
+ const { user } = useUser();
return (
<>
- {/**/}
+ />
- {/**/}
+ />
>
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";
diff --git a/apps/server/src/game/game.ts b/apps/server/src/game/game.ts
index 912f52d..3336421 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();
} else {
this.scheduleTurnTimer();
}
@@ -186,6 +187,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");
@@ -254,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() {
+ const gameStateInfo = getGameOverInfo(this.chess);
+ if (!gameStateInfo) return;
+ this.GameStateEvent = gameStateInfo;
this.clearDisconnectTimeouts();
-
- this.GameStateEvent = {
- gameOver: true,
- reason,
- winnerColor,
- };
}
private assertPlayerCanMove(playerId: string) {
diff --git a/apps/server/src/sockets/socket.ts b/apps/server/src/sockets/socket.ts
index d9aee5c..b6226b5 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";
@@ -83,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({
@@ -126,19 +125,19 @@ 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");
+ io.to(gameId).emit("game:game-started", {
+ timeInfo: game.getTimeInfo(),
+ } satisfies GameStartedEvent);
cb({
ok: true,
@@ -167,8 +166,11 @@ 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 } satisfies MoveMadeEvent);
if (game.isGameOver()) {
io.to(gameId).emit("game:game-over", game.getGameStateEvent());
@@ -178,6 +180,7 @@ io.on("connection", (socket) => {
ok: true,
fen,
turn,
+ timeInfo,
});
} catch (err) {
cb({
@@ -276,6 +279,7 @@ io.on("connection", (socket) => {
opponentId: game.getMyOpponent(userId),
fen: game.getFEN(),
turn: game.getTurn(),
+ timeInfo: game.getTimeInfo(),
});
} 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/package.json b/packages/shared/package.json
index 4199a2b..896e214 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -15,5 +15,8 @@
"type": "module",
"devDependencies": {
"typescript": "^6.0.2"
+ },
+ "dependencies": {
+ "chess.js": "^1.4.0"
}
}
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 8c54483..1332c7d 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 =
@@ -67,6 +67,7 @@ export type GameMoveAck =
ok: true;
fen: string;
turn: PlayerColor;
+ timeInfo: TimeInfo;
}
| {
ok: false;
@@ -81,6 +82,7 @@ export type GameSync =
opponentId: string | undefined;
fen: string;
turn: PlayerColor;
+ timeInfo: TimeInfo;
}
| {
ok: false;
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
new file mode 100644
index 0000000..56dffc6
--- /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.isStalemate()) reason = "Stalemate";
+ else if (chess.isInsufficientMaterial()) reason = "Insufficient Material";
+ else if (chess.isThreefoldRepetition()) reason = "Threefold Repetition";
+ else if (chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule";
+ }
+
+ return {
+ gameOver: true,
+ reason,
+ winnerColor,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc79f32..c91ad2b 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,6 +191,10 @@ importers:
version: 6.0.3
packages/shared:
+ dependencies:
+ chess.js:
+ specifier: ^1.4.0
+ version: 1.4.0
devDependencies:
typescript:
specifier: ^6.0.2
@@ -1683,10 +1687,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
@@ -5250,7 +5254,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