diff --git a/next.config.ts b/next.config.ts
index 6a646a07..829136f8 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -60,6 +60,19 @@ const nextConfig = (phase: string): NextConfig => ({
},
],
},
+ {
+ source: "/playground",
+ headers: [
+ {
+ key: "Cross-Origin-Embedder-Policy",
+ value: "require-corp",
+ },
+ {
+ key: "Cross-Origin-Opener-Policy",
+ value: "same-origin",
+ },
+ ],
+ },
{
source: "/database",
headers: [
diff --git a/src/pages/playground.tsx b/src/pages/playground.tsx
new file mode 100644
index 00000000..db574d92
--- /dev/null
+++ b/src/pages/playground.tsx
@@ -0,0 +1,85 @@
+import { PageTitle } from "@/components/pageTitle";
+import { useAtomLocalStorage } from "@/hooks/useAtomLocalStorage";
+import { useEngine } from "@/hooks/useEngine";
+import { Stockfish16_1 } from "@/lib/engine/stockfish16_1";
+import { isEngineSupported } from "@/lib/engine/shared";
+import PlaygroundBoard from "@/sections/playground/board";
+import PlaygroundEngineLines from "@/sections/playground/engineLines";
+import { usePlaygroundCurrentPosition } from "@/sections/playground/hooks/useCurrentPosition";
+import PlaygroundToolbar from "@/sections/playground/toolbar";
+import { engineNameAtom } from "@/sections/analysis/states";
+import { DEFAULT_ENGINE, ENGINE_LABELS } from "@/constants";
+import { EngineName } from "@/types/enums";
+import { Grid2 as Grid, Typography } from "@mui/material";
+import { useEffect } from "react";
+
+export default function Playground() {
+ const [engineName, setEngineName] = useAtomLocalStorage(
+ "engine-name",
+ engineNameAtom
+ );
+ const engine = useEngine(engineName);
+
+ useEffect(() => {
+ if (!isEngineSupported(engineName)) {
+ if (Stockfish16_1.isSupported()) {
+ setEngineName(EngineName.Stockfish16_1Lite);
+ } else {
+ setEngineName(EngineName.Stockfish11);
+ }
+ }
+ }, [engineName, setEngineName]);
+
+ usePlaygroundCurrentPosition(engine);
+
+ return (
+
+
+
+
+
+
+
+
+ Playground
+
+
+ Free play with live analysis from{" "}
+ {ENGINE_LABELS[engineName ?? DEFAULT_ENGINE].small}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx
index 337b2299..4d06ac2f 100644
--- a/src/sections/layout/NavMenu.tsx
+++ b/src/sections/layout/NavMenu.tsx
@@ -13,6 +13,11 @@ import {
const MenuOptions = [
{ text: "Play", icon: "streamline:chess-pawn", href: "/play" },
+ {
+ text: "Playground",
+ icon: "mdi:flask-outline",
+ href: "/playground",
+ },
{ text: "Analysis", icon: "streamline:magnifying-glass-solid", href: "/" },
{
text: "Database",
diff --git a/src/sections/playground/board.tsx b/src/sections/playground/board.tsx
new file mode 100644
index 00000000..48d0e2c1
--- /dev/null
+++ b/src/sections/playground/board.tsx
@@ -0,0 +1,43 @@
+import Board from "@/components/board";
+import { usePlayersData } from "@/hooks/usePlayersData";
+import { useScreenSize } from "@/hooks/useScreenSize";
+import { showPlayerMoveIconAtom } from "@/sections/analysis/states";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import {
+ playgroundBoardOrientationAtom,
+ playgroundCurrentPositionAtom,
+ playgroundGameAtom,
+} from "./states";
+
+export default function PlaygroundBoard() {
+ const screenSize = useScreenSize();
+ const boardOrientation = useAtomValue(playgroundBoardOrientationAtom);
+ const { white, black } = usePlayersData(playgroundGameAtom);
+
+ const boardSize = useMemo(() => {
+ const width = screenSize.width;
+ const height = screenSize.height;
+
+ if (window?.innerWidth < 1200) {
+ return Math.min(width, height - 150);
+ }
+
+ return Math.min(width - 700, height * 0.92);
+ }, [screenSize]);
+
+ return (
+
+ );
+}
diff --git a/src/sections/playground/engineLines.tsx b/src/sections/playground/engineLines.tsx
new file mode 100644
index 00000000..b9f1988f
--- /dev/null
+++ b/src/sections/playground/engineLines.tsx
@@ -0,0 +1,123 @@
+import PrettyMoveSan from "@/components/prettyMoveSan";
+import { useChessActions } from "@/hooks/useChessActions";
+import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess";
+import { LineEval } from "@/types/eval";
+import { List, ListItem, Skeleton, Stack, Typography } from "@mui/material";
+import { useAtomValue } from "jotai";
+import { playgroundCurrentPositionAtom, playgroundGameAtom } from "./states";
+
+const linesSkeleton: LineEval[] = Array.from({ length: 3 }).map((_, i) => ({
+ pv: [`${i}`],
+ depth: 0,
+ multiPv: i + 1,
+}));
+
+export default function PlaygroundEngineLines() {
+ const game = useAtomValue(playgroundGameAtom);
+ const position = useAtomValue(playgroundCurrentPositionAtom);
+ const { addMoves } = useChessActions(playgroundGameAtom);
+
+ const lines = position.eval?.lines?.length
+ ? position.eval.lines.slice(0, 3)
+ : linesSkeleton;
+ const uciToSan = moveLineUciToSan(game.fen());
+ const turn = game.turn();
+
+ const getColorFromMoveIdx = (moveIdx: number): "w" | "b" => {
+ if (moveIdx % 2 === 0) return turn;
+ return turn === "w" ? "b" : "w";
+ };
+
+ return (
+
+
+ Stockfish top lines
+ {position.opening && (
+
+ {position.opening}
+
+ )}
+
+
+ {game.isGameOver() && (
+
+ Game is over
+
+ )}
+
+
+ {lines.map((line) => {
+ const showSkeleton = line.depth < 6;
+ const isBlackEval =
+ (line.cp !== undefined && line.cp < 0) ||
+ (line.mate !== undefined && line.mate < 0);
+
+ return (
+
+
+ {showSkeleton ? (
+
+ placeholder
+
+ ) : (
+ getLineEvalLabel(line)
+ )}
+
+
+
+ {showSkeleton ? (
+
+ ) : (
+ line.pv.map((uci, i) => (
+ addMoves(line.pv.slice(0, i + 1)),
+ sx: {
+ cursor: "pointer",
+ ml: i ? 0.5 : 0,
+ transition: "opacity 0.2s ease-in-out",
+ "&:hover": {
+ opacity: 0.5,
+ },
+ },
+ }}
+ />
+ ))
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/sections/playground/hooks/useCurrentPosition.ts b/src/sections/playground/hooks/useCurrentPosition.ts
new file mode 100644
index 00000000..5f94f230
--- /dev/null
+++ b/src/sections/playground/hooks/useCurrentPosition.ts
@@ -0,0 +1,173 @@
+import { openings } from "@/data/openings";
+import { getEvaluateGameParams } from "@/lib/chess";
+import { getMovesClassification } from "@/lib/engine/helpers/moveClassification";
+import { UciEngine } from "@/lib/engine/uciEngine";
+import { CurrentPosition, PositionEval } from "@/types/eval";
+import { useAtom, useAtomValue } from "jotai";
+import { useEffect, useRef } from "react";
+import {
+ playgroundCurrentPositionAtom,
+ playgroundGameAtom,
+ playgroundSavedEvalsAtom,
+} from "../states";
+
+const PLAYGROUND_MULTI_PV = 3;
+const PLAYGROUND_DEPTH = 14;
+
+export const usePlaygroundCurrentPosition = (engine: UciEngine | null) => {
+ const [currentPosition, setCurrentPosition] = useAtom(
+ playgroundCurrentPositionAtom
+ );
+ const game = useAtomValue(playgroundGameAtom);
+ const [savedEvals, setSavedEvals] = useAtom(playgroundSavedEvalsAtom);
+ const savedEvalsRef = useRef(savedEvals);
+ const inFlightEvalsRef = useRef>>({});
+
+ useEffect(() => {
+ savedEvalsRef.current = savedEvals;
+ }, [savedEvals]);
+
+ useEffect(() => {
+ const history = game.history({ verbose: true });
+ const gameFen = game.fen();
+ let isCancelled = false;
+
+ const position: CurrentPosition = {
+ lastMove: history.at(-1),
+ currentMoveIdx: history.length,
+ };
+
+ for (const move of history.slice().reverse()) {
+ const moveFen = move.after.split(" ")[0];
+ const opening = openings.find((opening) => opening.fen === moveFen);
+ if (opening) {
+ position.opening = opening.name;
+ break;
+ }
+ }
+
+ setCurrentPosition(position);
+
+ if (!engine?.getIsReady() || !engine.name || game.isGameOver()) {
+ return;
+ }
+
+ const getFenEval = async (
+ fen: string,
+ setPartialEval?: (positionEval: PositionEval) => void
+ ) => {
+ if (!engine.getIsReady()) {
+ throw new Error("Engine not ready");
+ }
+
+ const savedEval = savedEvalsRef.current[fen];
+ if (
+ savedEval &&
+ savedEval.engine === engine.name &&
+ (savedEval.lines?.length ?? 0) >= PLAYGROUND_MULTI_PV &&
+ (savedEval.lines[0]?.depth ?? 0) >= PLAYGROUND_DEPTH
+ ) {
+ const positionEval: PositionEval = {
+ ...savedEval,
+ lines: savedEval.lines.slice(0, PLAYGROUND_MULTI_PV),
+ };
+ setPartialEval?.(positionEval);
+ return positionEval;
+ }
+
+ const requestKey = [
+ engine.name,
+ fen,
+ PLAYGROUND_DEPTH,
+ PLAYGROUND_MULTI_PV,
+ ].join(":");
+
+ if (!inFlightEvalsRef.current[requestKey]) {
+ inFlightEvalsRef.current[requestKey] = engine
+ .evaluatePositionWithUpdate({
+ fen,
+ depth: PLAYGROUND_DEPTH,
+ multiPv: PLAYGROUND_MULTI_PV,
+ setPartialEval,
+ })
+ .finally(() => {
+ delete inFlightEvalsRef.current[requestKey];
+ });
+ }
+
+ const rawPositionEval = await inFlightEvalsRef.current[requestKey];
+
+ if (rawPositionEval.lines.length === 0) {
+ return rawPositionEval;
+ }
+
+ setSavedEvals((prev) => ({
+ ...prev,
+ [fen]: { ...rawPositionEval, engine: engine.name },
+ }));
+
+ return rawPositionEval;
+ };
+
+ const getPositionEval = async () => {
+ const setPartialEval = (positionEval: PositionEval) => {
+ if (isCancelled || gameFen !== game.fen()) return;
+ setCurrentPosition((prev) => ({ ...prev, eval: positionEval }));
+ };
+
+ const currentEval = await getFenEval(game.fen(), setPartialEval);
+ if (isCancelled || gameFen !== game.fen()) return;
+
+ if (!history.length) return;
+
+ const params = getEvaluateGameParams(game);
+ const fens = params.fens.slice(game.turn() === "w" ? -3 : -4);
+ const uciMoves = params.uciMoves.slice(game.turn() === "w" ? -2 : -3);
+
+ const previousFen = fens.slice(-2)[0];
+ if (!previousFen) return;
+
+ const previousEval = await getFenEval(previousFen);
+ if (isCancelled || gameFen !== game.fen()) return;
+
+ const rawPositions: PositionEval[] = fens.map((_, idx) => {
+ if (idx === fens.length - 2) return previousEval;
+ if (idx === fens.length - 1) return currentEval;
+
+ return {
+ lines: [
+ {
+ pv: [],
+ depth: 0,
+ multiPv: 1,
+ cp: 1,
+ },
+ ],
+ };
+ });
+
+ const positionsWithMoveClassification = getMovesClassification(
+ rawPositions,
+ uciMoves,
+ fens
+ );
+
+ setCurrentPosition((prev) => ({
+ ...prev,
+ eval: positionsWithMoveClassification.slice(-1)[0],
+ lastEval: positionsWithMoveClassification.slice(-2)[0],
+ }));
+ };
+
+ getPositionEval();
+
+ return () => {
+ isCancelled = true;
+ if (engine.getIsReady()) {
+ engine.stopAllCurrentJobs();
+ }
+ };
+ }, [engine, game, setCurrentPosition, setSavedEvals]);
+
+ return currentPosition;
+};
diff --git a/src/sections/playground/newPlaygroundDialog.tsx b/src/sections/playground/newPlaygroundDialog.tsx
new file mode 100644
index 00000000..942f71af
--- /dev/null
+++ b/src/sections/playground/newPlaygroundDialog.tsx
@@ -0,0 +1,90 @@
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { useState } from "react";
+import { useChessActions } from "@/hooks/useChessActions";
+import { getGameFromPgn } from "@/lib/chess";
+import { playgroundGameAtom } from "./states";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function NewPlaygroundDialog({ open, onClose }: Props) {
+ const { reset } = useChessActions(playgroundGameAtom);
+ const [startingPositionInput, setStartingPositionInput] = useState("");
+ const [parsingError, setParsingError] = useState("");
+
+ const handleClose = () => {
+ onClose();
+ setStartingPositionInput("");
+ setParsingError("");
+ };
+
+ const handleStart = () => {
+ setParsingError("");
+
+ try {
+ const input = startingPositionInput.trim();
+ const startingFen = input.startsWith("[")
+ ? getGameFromPgn(input).fen()
+ : input || undefined;
+
+ reset({
+ fen: startingFen,
+ white: { name: "White" },
+ black: { name: "Black" },
+ });
+ handleClose();
+ } catch (error) {
+ console.error(error);
+ setParsingError(
+ error instanceof Error
+ ? `${error.message} !`
+ : "Unknown error while parsing input !"
+ );
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/sections/playground/states.ts b/src/sections/playground/states.ts
new file mode 100644
index 00000000..5e940c0c
--- /dev/null
+++ b/src/sections/playground/states.ts
@@ -0,0 +1,16 @@
+import { CurrentPosition, SavedEvals } from "@/types/eval";
+import { Color } from "@/types/enums";
+import { setGameHeaders } from "@/lib/chess";
+import { Chess } from "chess.js";
+import { atom } from "jotai";
+
+export const createPlaygroundGame = (fen?: string) =>
+ setGameHeaders(new Chess(fen), {
+ white: { name: "White" },
+ black: { name: "Black" },
+ });
+
+export const playgroundGameAtom = atom(createPlaygroundGame());
+export const playgroundCurrentPositionAtom = atom({});
+export const playgroundSavedEvalsAtom = atom({});
+export const playgroundBoardOrientationAtom = atom(Color.White);
diff --git a/src/sections/playground/toolbar.tsx b/src/sections/playground/toolbar.tsx
new file mode 100644
index 00000000..2e95f2c9
--- /dev/null
+++ b/src/sections/playground/toolbar.tsx
@@ -0,0 +1,88 @@
+import { useChessActions } from "@/hooks/useChessActions";
+import { setGameHeaders } from "@/lib/chess";
+import { Color } from "@/types/enums";
+import { Icon } from "@iconify/react";
+import { Button, Grid2 as Grid, IconButton, Tooltip } from "@mui/material";
+import { useAtom, useAtomValue } from "jotai";
+import { Chess } from "chess.js";
+import { useState } from "react";
+import NewPlaygroundDialog from "./newPlaygroundDialog";
+import { playgroundBoardOrientationAtom, playgroundGameAtom } from "./states";
+
+export default function PlaygroundToolbar() {
+ const game = useAtomValue(playgroundGameAtom);
+ const { undoMove } = useChessActions(playgroundGameAtom);
+ const [boardOrientation, setBoardOrientation] = useAtom(
+ playgroundBoardOrientationAtom
+ );
+ const [openDialog, setOpenDialog] = useState(false);
+
+ const handleCopyPgn = () => {
+ if (!game.history().length) return;
+
+ const exportGame = new Chess();
+ exportGame.loadPgn(game.pgn());
+ setGameHeaders(exportGame);
+ navigator.clipboard?.writeText?.(exportGame.pgn());
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ undoMove()}
+ disabled={game.history().length === 0}
+ sx={{ paddingX: 1.2, paddingY: 0.5 }}
+ >
+
+
+
+
+
+
+
+
+
+ setBoardOrientation(
+ boardOrientation === Color.White ? Color.Black : Color.White
+ )
+ }
+ sx={{ paddingX: 1.2, paddingY: 0.5 }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setOpenDialog(false)}
+ />
+ >
+ );
+}