feat(chess): replace custom engine with chess.js and react-chessboard
Some checks failed
Deploy to Production / test (push) Failing after 39s

Swap the custom move validation and Unicode piece grid for chess.js
(full rules engine with check/checkmate/castling/en passant/promotion)
and react-chessboard (drag-and-drop SVG board). Board styled to match
the purple dark theme and auto-orients to the player's color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 14:22:27 +02:00
parent 5527981fff
commit 0dadc82f84
7 changed files with 195 additions and 212 deletions

View File

@@ -10,10 +10,12 @@
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"chess.js": "^1.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",
"react": "^19.1.0",
"react-chessboard": "^5.10.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.13.2",
"tailwind-merge": "^3.4.0",

View File

@@ -1,61 +1,104 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import type { GameUIProps } from "../registry";
interface Piece {
type: string;
color: "white" | "black";
}
interface ChessState {
board: (Piece | null)[][];
currentTurn: "white" | "black";
fen: string;
players: { white: string; black: string };
moveHistory: string[];
status: string;
winner: string | null;
}
const PIECE_SYMBOLS: Record<string, Record<string, string>> = {
white: { king: "♔", queen: "♕", rook: "♖", bishop: "♗", knight: "♘", pawn: "♙" },
black: { king: "♚", queen: "♛", rook: "♜", bishop: "♝", knight: "♞", pawn: "♟" },
};
export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
const chess = state as ChessState;
const [selected, setSelected] = useState<[number, number] | null>(null);
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
const [promotionTo, setPromotionTo] = useState<string | null>(null);
if (!chess?.board) {
const game = useMemo(() => {
if (!chess?.fen) return null;
return new Chess(chess.fen);
}, [chess?.fen]);
if (!game) {
return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
}
const myColor = chess.players.white === myPlayerId ? "white" : chess.players.black === myPlayerId ? "black" : null;
const isMyTurn = myColor === chess.currentTurn && !isSpectator;
function handleSquareClick(row: number, col: number) {
if (isSpectator || !isMyTurn) return;
if (selected) {
onAction({ type: "move", from: selected, to: [row, col] });
setSelected(null);
} else {
const piece = chess.board[row][col];
if (piece && piece.color === myColor) {
setSelected([row, col]);
}
}
}
function handleForfeit() {
onAction({ type: "forfeit" });
}
const turn = game.turn() === "w" ? "white" : "black";
const isMyTurn = myColor === turn && !isSpectator;
const boardOrientation = myColor ?? "white";
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
const opponent = players.find(p => p.discordId === opponentId);
const me = players.find(p => p.discordId === myPlayerId);
function onDrop(sourceSquare: string, targetSquare: string): boolean {
if (isSpectator || !isMyTurn) return false;
const testGame = new Chess(chess.fen);
// Check if this is a promotion move
const piece = testGame.get(sourceSquare as any);
if (piece?.type === "p") {
const targetRank = targetSquare[1];
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
setPromotionFrom(sourceSquare);
setPromotionTo(targetSquare);
return true; // allow the visual drop, handle promotion via dialog
}
}
const move = testGame.move({ from: sourceSquare, to: targetSquare });
if (!move) return false;
onAction({ type: "move", from: sourceSquare, to: targetSquare });
return true;
}
function handlePromotion(piece: string) {
if (promotionFrom && promotionTo) {
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
onAction({ type: "move", from: promotionFrom, to: promotionTo, promotion: promotionPiece });
}
setPromotionFrom(null);
setPromotionTo(null);
return true;
}
function isDraggablePiece({ piece }: { piece: string }): boolean {
if (isSpectator || !isMyTurn) return false;
const pieceColor = piece[0] === "w" ? "white" : "black";
return pieceColor === myColor;
}
// Highlight king in check
const customSquareStyles: Record<string, React.CSSProperties> = {};
if (game.inCheck()) {
// Find the king square of the player in check
const board = game.board();
const kingColor = game.turn();
for (let r = 0; r < 8; r++) {
for (let c = 0; c < 8; c++) {
const sq = board[r][c];
if (sq?.type === "k" && sq.color === kingColor) {
const file = String.fromCharCode(97 + c);
const rank = 8 - r;
customSquareStyles[`${file}${rank}`] = {
backgroundColor: "rgba(220, 38, 38, 0.45)",
borderRadius: "50%",
};
}
}
}
}
return (
<div className="flex gap-4">
<div>
{/* Opponent info */}
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
@@ -64,30 +107,28 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
</div>
<div className="inline-grid grid-cols-8 border-2 border-border rounded overflow-hidden">
{chess.board.map((row, r) =>
row.map((piece, c) => {
const isLight = (r + c) % 2 === 0;
const isSelected = selected?.[0] === r && selected?.[1] === c;
return (
<button
key={`${r}-${c}`}
onClick={() => handleSquareClick(r, c)}
className={`w-12 h-12 flex items-center justify-center text-2xl transition-colors ${
isSelected
? "bg-primary/40"
: isLight
? "bg-raised"
: "bg-surface"
} ${isMyTurn ? "cursor-pointer hover:bg-primary/20" : "cursor-default"}`}
>
{piece ? PIECE_SYMBOLS[piece.color][piece.type] : ""}
</button>
);
})
)}
<div className="rounded overflow-hidden border-2 border-border">
<Chessboard
position={chess.fen}
onPieceDrop={onDrop}
onPromotionPieceSelect={handlePromotion}
boardOrientation={boardOrientation}
isDraggablePiece={isDraggablePiece}
boardWidth={400}
showPromotionDialog={promotionFrom !== null}
promotionToSquare={promotionTo as any}
animationDuration={200}
customSquareStyles={customSquareStyles}
customDarkSquareStyle={{ backgroundColor: "#2D2A5F" }}
customLightSquareStyle={{ backgroundColor: "#1E1B4B" }}
customBoardStyle={{ borderRadius: "0" }}
customDropSquareStyle={{ boxShadow: "inset 0 0 1px 4px rgba(139, 92, 246, 0.5)" }}
customPremoveDarkSquareStyle={{ backgroundColor: "#4c1d95" }}
customPremoveLightSquareStyle={{ backgroundColor: "#5b21b6" }}
/>
</div>
{/* Player info */}
<div className="flex items-center gap-2 mt-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
{me?.username?.[0]?.toUpperCase() ?? "?"}
@@ -100,6 +141,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-3 min-w-[180px]">
<div className="bg-card rounded-lg border border-border">
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
@@ -121,7 +163,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
{!isSpectator && chess.status === "playing" && (
<button
onClick={handleForfeit}
onClick={() => onAction({ type: "forfeit" })}
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
>
Forfeit