Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,521 changes: 1,521 additions & 0 deletions apps/client/public/stockfish/stockfish-18-lite-single.js

Large diffs are not rendered by default.

Binary file not shown.
46 changes: 46 additions & 0 deletions apps/client/src/components/Timer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { cn } from "../utils/cn";

interface TimerType {
side: "w" | "b";
whiteDisplayTime: number;
blackDisplayTime: number;
currentTurn: "w" | "b";
playerName: string | undefined;
}

export function Timer({
side,
blackDisplayTime,
whiteDisplayTime,
currentTurn,
playerName = "Random Player",
}: TimerType) {
const isActive = side === currentTurn;
const displayTime = side === "w" ? whiteDisplayTime : blackDisplayTime;
return (
<div className="m-2 flex items-center justify-between rounded-lg border border-[#424A35]/30 bg-[#20201E] p-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined">person</span>
<span className="text-lg text-[#E5E2DE]">{playerName}</span>
</div>

<div
className={cn(
"rounded border border-[#424A35] bg-[#2A2A28] px-4 py-2 text-[#E5E2DE]",
{ "bg-[#8cdd12] text-[#203600]": isActive },
)}
>
<span className={cn("font-mono text-lg font-semibold")}>
{formatTime(displayTime)}
</span>
</div>
</div>
);
}

function formatTime(ms: number) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
59 changes: 59 additions & 0 deletions apps/client/src/components/chessSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { GameOverInfo } from "@chesslab/shared/types";

export default function SideBar({
gameHistory,
gameOverInfo,
}: {
gameHistory: string[] | undefined;
gameOverInfo: GameOverInfo | undefined;
}) {
return (
<div className="fixed top-0 right-0 hidden h-full w-120 border-l border-[#424A35] bg-[#1C1C1A] p-4 sm:block">
{gameOverInfo && (
<div className="mb-4 rounded-lg border border-[#424A35] bg-[#20201E] p-4">
<h2 className="mb-2 text-lg font-semibold text-[#E5E2DE]">
Game Over
</h2>
<p className="text-[#E5E2DE]">
Reason: {gameOverInfo.reason}
</p>
<p className="text-[#E5E2DE]">
Winner:{" "}
{gameOverInfo.winner === "d"
? "Draw"
: gameOverInfo.winner === "w"
? "White"
: "Black"}
</p>
</div>
)}

{gameHistory && (
<table className="w-full table-fixed text-sm">
<tbody>
{Array(Math.ceil(gameHistory.length / 2))
.fill(undefined)
.map((_, row) => (
<tr
key={row}
className="border-b border-zinc-800 hover:bg-zinc-800/50"
>
<td className="w-10 py-1 text-center text-zinc-500">
{row + 1}.
</td>

<td className="px-3 py-1 font-medium">
{gameHistory[row * 2]}
</td>

<td className="px-3 py-1 font-medium">
{gameHistory[row * 2 + 1] ?? ""}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
54 changes: 54 additions & 0 deletions apps/client/src/hooks/useStockfish.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect } from "react";
import stockfish from "../stockfish/stockfish";
import type { Chess } from "chess.js";

export default function useStockfish({
chessGame,
turn,
stockfishSide,
onMove,
}: {
chessGame: Chess;
turn: "w" | "b";
stockfishSide: "w" | "b";
onMove: () => void;
}) {
useEffect(() => {
stockfish.postMessage("uci");
stockfish.postMessage("isready");
stockfish.postMessage("ucinewgame");

stockfish.onmessage = (e) => {
const message = e.data;
if (message.startsWith("bestmove")) {
const parts = message.split(" ");
const bestMove = parts[1];
if (bestMove && bestMove !== "(none)") {
try {
if (chessGame.isGameOver()) return;
chessGame.move({
from: bestMove.substring(0, 2),
to: bestMove.substring(2, 4),
promotion: bestMove.charAt(4) || "q",
});
onMove();
} catch (err) {
console.error("Error applying Stockfish move:", err);
}
}
}
};

return () => {
stockfish.postMessage("stop");
stockfish.onmessage = null;
};
Comment on lines +21 to +45
}, [chessGame, onMove]);
Comment on lines +16 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Init effect re-runs every render and interrupts the engine.

onMove is supplied inline from ComputerChessBoard (onMove: () => {…}), so its identity changes on every render. Because ComputerChessBoard re-renders roughly once per second (the useTimer countdown updates state), this effect re-runs on every tick: it re-sends uci/isready/ucinewgame (resetting engine state mid-game) and its cleanup posts stop, aborting any search that is in flight. The result is unstable Stockfish behavior.

Stabilize the callback with a ref so initialization runs once per game:

🔧 Proposed fix
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
 import stockfish from "../stockfish/stockfish";
 import type { Chess } from "chess.js";
@@
 }) {
+	const onMoveRef = useRef(onMove);
+	onMoveRef.current = onMove;
+
 	useEffect(() => {
 		stockfish.postMessage("uci");
 		stockfish.postMessage("isready");
 		stockfish.postMessage("ucinewgame");
@@
-						onMove();
+						onMoveRef.current();
 					} catch (err) {
 						console.error("Error applying Stockfish move:", err);
 					}
 				}
 			}
 		};
@@
 		return () => {
 			stockfish.postMessage("stop");
 			stockfish.onmessage = null;
 		};
-	}, [chessGame, onMove]);
+	}, [chessGame]);

Alternatively, wrap onMove in useCallback at the ComputerChessBoard call site (Lines 31-36 of computerGame.tsx), but the ref keeps the fix local to the hook.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
stockfish.postMessage("uci");
stockfish.postMessage("isready");
stockfish.postMessage("ucinewgame");
stockfish.onmessage = (e) => {
const message = e.data;
if (message.startsWith("bestmove")) {
const parts = message.split(" ");
const bestMove = parts[1];
if (bestMove && bestMove !== "(none)") {
try {
if (chessGame.isGameOver()) return;
chessGame.move({
from: bestMove.substring(0, 2),
to: bestMove.substring(2, 4),
promotion: bestMove.charAt(4) || "q",
});
onMove();
} catch (err) {
console.error("Error applying Stockfish move:", err);
}
}
}
};
return () => {
stockfish.postMessage("stop");
stockfish.onmessage = null;
};
}, [chessGame, onMove]);
import { useEffect, useRef } from "react";
import stockfish from "../stockfish/stockfish";
import type { Chess } from "chess.js";
const onMoveRef = useRef(onMove);
onMoveRef.current = onMove;
useEffect(() => {
stockfish.postMessage("uci");
stockfish.postMessage("isready");
stockfish.postMessage("ucinewgame");
stockfish.onmessage = (e) => {
const message = e.data;
if (message.startsWith("bestmove")) {
const parts = message.split(" ");
const bestMove = parts[1];
if (bestMove && bestMove !== "(none)") {
try {
if (chessGame.isGameOver()) return;
chessGame.move({
from: bestMove.substring(0, 2),
to: bestMove.substring(2, 4),
promotion: bestMove.charAt(4) || "q",
});
onMoveRef.current();
} catch (err) {
console.error("Error applying Stockfish move:", err);
}
}
}
};
return () => {
stockfish.postMessage("stop");
stockfish.onmessage = null;
};
}, [chessGame]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/src/hooks/useStockfish.tsx` around lines 16 - 46, Stabilize the
onMove callback used by the useEffect in useStockfish.tsx with a ref, updating
the ref when the callback changes while keeping the Stockfish initialization and
cleanup effect from depending on onMove. Invoke the current ref callback when
applying a best move so rerenders do not resend uci/isready/ucinewgame or stop
an active search.


useEffect(() => {
if (turn === stockfishSide && !chessGame.isGameOver()) {
stockfish.postMessage(`position fen ${chessGame.fen()}`);
stockfish.postMessage("go depth 15");
}
}, [turn, chessGame, stockfishSide]);
}
34 changes: 34 additions & 0 deletions apps/client/src/hooks/useTimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { GameOverInfo } from "@chesslab/shared/types";
import { useEffect, useState } from "react";

export default function useTimer({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/useTimer.tsx:4

The timer clamps the active clock at 0 but never reports timeout to the caller, so when a player's clock reaches 0:00 the game continues indefinitely — the interval keeps firing and the opponent's moves remain legal. This happens because the hook only tracks local display state via setWhiteDisplayTime/setBlackDisplayTime and returns those values, with no callback or state to signal that a flag fell. Consider exposing a timeout event (e.g., an onTimeout callback or a derived timeout value in the return) so the caller can terminate the game and set gameOverInfo when a clock hits zero.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/client/src/hooks/useTimer.tsx around line 4:

The timer clamps the active clock at `0` but never reports timeout to the caller, so when a player's clock reaches `0:00` the game continues indefinitely — the interval keeps firing and the opponent's moves remain legal. This happens because the hook only tracks local display state via `setWhiteDisplayTime`/`setBlackDisplayTime` and returns those values, with no callback or state to signal that a flag fell. Consider exposing a timeout event (e.g., an `onTimeout` callback or a derived `timeout` value in the return) so the caller can terminate the game and set `gameOverInfo` when a clock hits zero.

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);
Comment on lines +18 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching useTimer:"
fd -a 'useTimer' . || true

echo
echo "Candidate files:"
git ls-files | rg '(^|/)useTimer\.tsx$|useTimer\.(tsx|ts)$' || true

echo
echo "Changed files stat (if available):"
git diff --stat HEAD~1..HEAD 2>/dev/null || git diff --stat 2>/dev/null || true

echo
echo "Search for useTimer/hooks usage:"
rg -n "useTimer|WhiteDisplayTime|BlackDisplayTime|Timer" apps/client/src || true

Repository: MohamedSayed0573/ChessLab

Length of output: 2234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "useTimer.tsx:"
cat -n apps/client/src/hooks/useTimer.tsx

echo
echo "Timer.tsx:"
cat -n apps/client/src/components/Timer.tsx

echo
echo "computerGame.tsx relevant sections:"
cat -n apps/client/src/pages/computerGame.tsx | sed -n '1,120p'

echo
echo "playerGame.tsx relevant sections:"
cat -n apps/client/src/pages/playerGame.tsx | sed -n '120,170p'

echo
echo "Search zero/timeout/expiry handling:"
rg -n "0|timeout|timed|win|draw|time|ended|gameover|game over|onWin|onEnd|isDraw|checkmate" apps/client/src/pages apps/client/src/components apps/client/src/hooks -g '!public/**' | sed -n '1,220p'

Repository: MohamedSayed0573/ChessLab

Length of output: 20108


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "playerGame.tsx timer/game state:"
cat -n apps/client/src/pages/playerGame.tsx | sed -n '1,125p'

echo
echo "computerGame.tsx top:"
cat -n apps/client/src/pages/computerGame.tsx | sed -n '13,25p'

echo
echo "Shared types GameOverInfo:"
rg -n "type GameOverInfo|interface GameOverInfo|GameOverInfo" -S . | sed -n '1,80p'

echo
echo "Read-only JavaScript setInterval throttle probe (non-blocking timing):"
node - <<'JS'
async function probe() {
  const start = performance.now();
  let fired = 0;
  const one = new Promise((resolve) => {
    const id = setInterval(() => {
      fired += 1;
      clearInterval(id);
      resolve();
    }, 1000);
  });
  await one;
  const delta = performance.now() - start;
  console.logJSON({ oneFired, elapsedSeconds: Math.round(delta / 1000 * 1000) / 1000 });
}
function consoleJSON(obj) {
  process.stdout.write(JSON.stringify(obj) + "\n");
}
consoleJSON.probe = () => probe();
Object.assign(consoleJSON.probe, console);
probe();
JS

echo
echo "Static check: hook return values and timer props vs game-over logic"
python3 - <<'PY'
from pathlib import Path
files = {
    "hooks": "apps/client/src/hooks/useTimer.tsx",
    "computer": "apps/client/src/pages/computerGame.tsx",
    "player": "apps/client/src/pages/playerGame.tsx",
}
for name, path in files.items():
    s = Path(path).read_text()
    print(f"\n== {name}: {path} ==")
    print("return { whiteDisplayTime, blackDisplayTime }:", "return { whiteDisplayTime, blackDisplayTime }" in s)
    print("onMove sets isDraw:", "isDraw" in s and "setGameOverInfo" in s)
    print("relevance: turns only use display time values passed through Timer")
PY

Repository: MohamedSayed0573/ChessLab

Length of output: 6943


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "packages/shared/src/types.ts GameOverInfo:"
cat -n packages/shared/src/types.ts | sed -n '1,80p'

echo
echo "apps/server/src/sockets/socket.ts game-over handling:"
cat -n apps/server/src/sockets/socket.ts | sed -n '180,200p'

Repository: MohamedSayed0573/ChessLab

Length of output: 1883


Count down against the wall clock and expose timeout results.

setInterval(() => prev - 1000, 1000) can drift under browser throttling, and this hook stops at 0 without emitting a timeout. Make the countdown wall-clock based, ensure the player-game state is also updated the same way, and let the hook/consumer emit an explicit timeout win/draw status for when a side reaches zero.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/src/hooks/useTimer.tsx` around lines 18 - 25, The countdown logic
in useTimer must use wall-clock elapsed time instead of decrementing by fixed
intervals, updating both display time and player-game state from the same
timestamp-based calculation. When either side reaches zero, stop the countdown
and expose an explicit timeout win/draw result through the hook’s
consumer-facing API, preserving normal updates while time remains.

}

return () => {
if (interval) clearInterval(interval);
};
}, [turn, gameOverInfo]);

return { whiteDisplayTime, blackDisplayTime };
}
114 changes: 75 additions & 39 deletions apps/client/src/pages/computerGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,110 @@ import {
type ChessboardOptions,
type PieceDropHandlerArgs,
} from "react-chessboard";
import { useRef, useState } from "react";
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";

export default function ComputerChessBoard() {
// create a chess game using a ref to always have access to the latest game state within closures and maintain the game state across renders
const chessGameRef = useRef(new Chess());

// track the current position of the chess game in state to trigger a re-render of the chessboard
const [chessGame] = useState(() => new Chess());
const [chessPosition, setChessPosition] = useState(() => new Chess().fen());
// make a random "CPU" move
function makeRandomMove() {
// get all possible moves`
const possibleMoves = chessGameRef.current.moves();

// exit if the game is over
if (
chessGameRef.current.isGameOver() ||
chessGameRef.current.turn() !== "b"
) {
return;
}
const [turn, setTurn] = useState<"w" | "b">("w");
const [gameHistory, setGameHistory] = useState<string[]>([]);
const gameOverInfo = getGameOverInfo(chessGame);
const [side] = useState<"w" | "b">(() => (Math.random() < 0.5 ? "w" : "b"));

// pick a random move
const randomMove =
possibleMoves[Math.floor(Math.random() * possibleMoves.length)];
const { whiteDisplayTime, blackDisplayTime } = useTimer({
turn,
gameOverInfo,
});

// make the move
chessGameRef.current.move(randomMove!);
useStockfish({
chessGame,
turn,
stockfishSide: side === "w" ? "b" : "w",
onMove: () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium pages/computerGame.tsx:31

The onMove callback passed to useStockfish is created inline on every render, so each timer tick (once per second) re-runs the effect, sends stop to Stockfish, and reinitializes the worker. A go depth 15 search that takes longer than one second is repeatedly canceled before completing, so Stockfish either plays a prematurely selected move or never moves at all. Store the callback in a ref (or memoize it with useCallback) so timer-driven renders don't restart the worker while a search is in progress.

Also found in 1 other location(s)

apps/client/src/hooks/useStockfish.tsx:46

The initialization effect depends on onMove, but the caller supplies a new inline callback on every render. Timer-driven renders therefore run this cleanup while Stockfish is searching, sending stop; UCI requires stop to terminate the search and emit bestmove, so the requested go depth 15 search is repeatedly cut short and the engine plays a prematurely selected move rather than completing the configured depth. Keep the callback in a ref or otherwise avoid restarting/stopping the worker merely because callback identity changed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/client/src/pages/computerGame.tsx around line 31:

The `onMove` callback passed to `useStockfish` is created inline on every render, so each timer tick (once per second) re-runs the effect, sends `stop` to Stockfish, and reinitializes the worker. A `go depth 15` search that takes longer than one second is repeatedly canceled before completing, so Stockfish either plays a prematurely selected move or never moves at all. Store the callback in a ref (or memoize it with `useCallback`) so timer-driven renders don't restart the worker while a search is in progress.

Also found in 1 other location(s):
- apps/client/src/hooks/useStockfish.tsx:46 -- The initialization effect depends on `onMove`, but the caller supplies a new inline callback on every render. Timer-driven renders therefore run this cleanup while Stockfish is searching, sending `stop`; UCI requires `stop` to terminate the search and emit `bestmove`, so the requested `go depth 15` search is repeatedly cut short and the engine plays a prematurely selected move rather than completing the configured depth. Keep the callback in a ref or otherwise avoid restarting/stopping the worker merely because callback identity changed.

setChessPosition(chessGame.fen());
setTurn(chessGame.turn());
setGameHistory(chessGame.history());
},
});

// update the position state
setChessPosition(chessGameRef.current.fen());
}

// handle piece drop
function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) {
// type narrow targetSquare potentially being null (e.g. if dropped off board)
if (!targetSquare || chessGameRef.current.turn() !== "w") {
if (chessGame.isGameOver()) return false;
Comment on lines 38 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High pages/computerGame.tsx:38

onPieceDrop doesn't check that chessGame.turn() matches the human player's side, so the player can drag Stockfish's pieces whenever it's the engine's turn (e.g. immediately at game start when side is "b", or while Stockfish is thinking), corrupting the turn sequence. Add a guard like if (chessGame.turn() !== side) return false; at the top of onPieceDrop.

 function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) {
- if (chessGame.isGameOver()) return false;
+ if (chessGame.isGameOver() || chessGame.turn() !== side) return false;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/client/src/pages/computerGame.tsx around lines 38-39:

`onPieceDrop` doesn't check that `chessGame.turn()` matches the human player's `side`, so the player can drag Stockfish's pieces whenever it's the engine's turn (e.g. immediately at game start when `side` is `"b"`, or while Stockfish is thinking), corrupting the turn sequence. Add a guard like `if (chessGame.turn() !== side) return false;` at the top of `onPieceDrop`.


if (!targetSquare) {
return false;
Comment on lines 38 to 42
}

try {
chessGameRef.current.move({
const move = chessGame.move({
from: sourceSquare,
to: targetSquare,
promotion: "q", // always promote to a queen for example simplicity
promotion: "q",
});

setChessPosition(chessGameRef.current.fen());
if (!move) return false;

setTimeout(makeRandomMove, 500);
setChessPosition(chessGame.fen());
setTurn(chessGame.turn());
setGameHistory(chessGame.history());

return true;
} catch {
// return false as the move was not successful
return false;
}
}

const chessboardOptions: ChessboardOptions = {
position: chessPosition,
position: chessPosition!,
onPieceDrop,
id: "play-vs-random",
//boardOrientation: color,
boardOrientation: side === "w" ? "white" : "black",
};

return (
<>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)_auto] bg-[#131312] sm:mr-120">
<Timer
side={side === "w" ? "b" : "w"}
blackDisplayTime={blackDisplayTime}
whiteDisplayTime={whiteDisplayTime}
currentTurn={turn}
playerName="Stockfish"
/>
<ChessBoard chessboardOptions={chessboardOptions} />
</>
<Timer
currentTurn={turn}
side={side}
blackDisplayTime={blackDisplayTime}
whiteDisplayTime={whiteDisplayTime}
playerName="Player"
/>
<SideBar gameHistory={gameHistory} gameOverInfo={gameOverInfo} />
</div>
);
}

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" };
}
}
Loading