feat(games): implement chess game plugin with full UI
Some checks failed
Deploy to Production / test (push) Failing after 32s

Add chess as the first game plugin using the existing multiplayer framework.
Server-side game logic uses chess.js with server-authoritative clock management.
Client uses react-chessboard v5 with cburnett piece set, drag-and-drop + click-to-move,
configurable time controls (bullet/blitz/rapid/classical/none), draw offers,
resignation, and timeout detection. Extends the game framework with room creation
options to support per-game configuration. Includes 57 tests covering all code paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-05 16:59:26 +02:00
parent 9e95194627
commit a29bb63a1d
26 changed files with 1644 additions and 19 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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><g fill="#000" stroke-linecap="butt"><path d="M9 36c3.4-1 10.1.4 13.5-2 3.4 2.4 10.1 1 13.5 2 0 0 1.6.5 3 2-.7 1-1.6 1-3 .5-3.4-1-10.1.5-13.5-1-3.4 1.5-10.1 0-13.5 1-1.4.5-2.3.5-3-.5 1.4-2 3-2 3-2z"/><path d="M15 32c2.5 2.5 12.5 2.5 15 0 .5-1.5 0-2 0-2 0-2.5-2.5-4-2.5-4 5.5-1.5 6-11.5-5-15.5-11 4-10.5 14-5 15.5 0 0-2.5 1.5-2.5 4 0 0-.5.5 0 2z"/><path d="M25 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 1 1 5 0z"/></g><path stroke="#ececec" stroke-linejoin="miter" d="M17.5 26h10M15 30h15m-7.5-14.5v5M20 18h5"/></g></svg>

After

Width:  |  Height:  |  Size: 683 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path stroke-linejoin="miter" d="M22.5 11.6V6"/><path fill="#000" stroke-linecap="butt" stroke-linejoin="miter" d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5"/><path fill="#000" d="M11.5 37a22.3 22.3 0 0 0 21 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-3.5-7.5-13-10.5-16-4-3 6 5 10 5 10z"/><path stroke-linejoin="miter" d="M20 8h5"/><path stroke="#ececec" d="M32 29.5s8.5-4 6-9.7C34.1 14 25 18 22.5 24.6v2.1-2.1C20 18 9.9 14 7 19.9c-2.5 5.6 4.8 9 4.8 9"/><path stroke="#ececec" d="M11.5 30c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0"/></g></svg>

After

Width:  |  Height:  |  Size: 783 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path fill="#000" d="M22 10c10.5 1 16.5 8 16 29H15c0-9 10-6.5 8-21"/><path fill="#000" d="M24 18c.38 2.91-5.55 7.37-8 9-3 2-2.82 4.34-5 4-1.04-.94 1.41-3.04 0-3-1 0 .19 1.23-1 2-1 0-4 1-4-4 0-2 6-12 6-12s1.89-1.9 2-3.5c-.73-1-.5-2-.5-3 1-1 3 2.5 3 2.5h2s.78-2 2.5-3c1 0 1 3 1 3"/><path fill="#ececec" stroke="#ececec" d="M9.5 25.5a.5.5 0 1 1-1 0 .5.5 0 1 1 1 0m5.43-9.75a.5 1.5 30 1 1-.86-.5.5 1.5 30 1 1 .86.5"/><path fill="#ececec" stroke="none" d="m24.55 10.4-.45 1.45.5.15c3.15 1 5.65 2.49 7.9 6.75S35.75 29.06 35.25 39l-.05.5h2.25l.05-.5c.5-10.06-.88-16.85-3.25-21.34s-5.79-6.64-9.19-7.16z"/></g></svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><path stroke="#000" stroke-linecap="round" stroke-width="1.5" d="M22.5 9a4 4 0 0 0-3.22 6.38 6.48 6.48 0 0 0-.87 10.65c-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47a6.46 6.46 0 0 0-.87-10.65A4.01 4.01 0 0 0 22.5 9z"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><g stroke="none"><circle cx="6" cy="12" r="2.75"/><circle cx="14" cy="9" r="2.75"/><circle cx="22.5" cy="8" r="2.75"/><circle cx="31" cy="9" r="2.75"/><circle cx="39" cy="12" r="2.75"/></g><path stroke-linecap="butt" d="M9 26c8.5-1.5 21-1.5 27 0l2.5-12.5L31 25l-.3-14.1-5.2 13.6-3-14.5-3 14.5-5.2-13.6L14 25 6.5 13.5z"/><path stroke-linecap="butt" d="M9 26c0 2 1.5 2 2.5 4 1 1.5 1 1 .5 3.5-1.5 1-1.5 2.5-1.5 2.5-1.5 1.5.5 2.5.5 2.5 6.5 1 16.5 1 23 0 0 0 1.5-1 0-2.5 0 0 .5-1.5-1-2.5-.5-2.5-.5-2 .5-3.5 1-2 2.5-2 2.5-4-8.5-1.5-18.5-1.5-27 0z"/><path fill="none" stroke-linecap="butt" d="M11 38.5a35 35 1 0 0 23 0"/><path fill="none" stroke="#ececec" d="M11 29a35 35 1 0 1 23 0m-21.5 2.5h20m-21 3a35 35 1 0 0 22 0m-23 3a35 35 1 0 0 24 0"/></g></svg>

After

Width:  |  Height:  |  Size: 910 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path stroke-linecap="butt" d="M9 39h27v-3H9zm3.5-7 1.5-2.5h17l1.5 2.5zm-.5 4v-4h21v4z"/><path stroke-linecap="butt" stroke-linejoin="miter" d="M14 29.5v-13h17v13z"/><path stroke-linecap="butt" d="M14 16.5 11 14h23l-3 2.5zM11 14V9h4v2h5V9h5v2h5V9h4v5z"/><path fill="none" stroke="#ececec" stroke-linejoin="miter" stroke-width="1" d="M12 35.5h21m-20-4h19m-18-2h17m-17-13h17M11 14h23"/></g></svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><g fill="#fff" stroke-linecap="butt"><path d="M9 36c3.39-.97 10.11.43 13.5-2 3.39 2.43 10.11 1.03 13.5 2 0 0 1.65.54 3 2-.68.97-1.65.99-3 .5-3.39-.97-10.11.46-13.5-1-3.39 1.46-10.11.03-13.5 1-1.35.49-2.32.47-3-.5 1.35-1.94 3-2 3-2z"/><path d="M15 32c2.5 2.5 12.5 2.5 15 0 .5-1.5 0-2 0-2 0-2.5-2.5-4-2.5-4 5.5-1.5 6-11.5-5-15.5-11 4-10.5 14-5 15.5 0 0-2.5 1.5-2.5 4 0 0-.5.5 0 2z"/><path d="M25 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 1 1 5 0z"/></g><path stroke-linejoin="miter" d="M17.5 26h10M15 30h15m-7.5-14.5v5M20 18h5"/></g></svg>

After

Width:  |  Height:  |  Size: 700 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path stroke-linejoin="miter" d="M22.5 11.63V6M20 8h5"/><path fill="#fff" stroke-linecap="butt" stroke-linejoin="miter" d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5"/><path fill="#fff" d="M11.5 37c5.5 3.5 15.5 3.5 21 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-3.5-7.5-13-10.5-16-4-3 6 5 10 5 10z"/><path d="M11.5 30c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0"/></g></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path fill="#fff" d="M22 10c10.5 1 16.5 8 16 29H15c0-9 10-6.5 8-21"/><path fill="#fff" d="M24 18c.38 2.91-5.55 7.37-8 9-3 2-2.82 4.34-5 4-1.042-.94 1.41-3.04 0-3-1 0 .19 1.23-1 2-1 0-4.003 1-4-4 0-2 6-12 6-12s1.89-1.9 2-3.5c-.73-.994-.5-2-.5-3 1-1 3 2.5 3 2.5h2s.78-1.992 2.5-3c1 0 1 3 1 3"/><path fill="#000" d="M9.5 25.5a.5.5 0 1 1-1 0 .5.5 0 1 1 1 0m5.433-9.75a.5 1.5 30 1 1-.866-.5.5 1.5 30 1 1 .866.5"/></g></svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><path fill="#fff" stroke="#000" stroke-linecap="round" stroke-width="1.5" d="M22.5 9c-2.21 0-4 1.79-4 4 0 .89.29 1.71.78 2.38C17.33 16.5 16 18.59 16 21c0 2.03.94 3.84 2.41 5.03-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47 1.47-1.19 2.41-3 2.41-5.03 0-2.41-1.33-4.5-3.28-5.62.49-.67.78-1.49.78-2.38 0-2.21-1.79-4-4-4z"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M8 12a2 2 0 1 1-4 0 2 2 0 1 1 4 0m16.5-4.5a2 2 0 1 1-4 0 2 2 0 1 1 4 0M41 12a2 2 0 1 1-4 0 2 2 0 1 1 4 0M16 8.5a2 2 0 1 1-4 0 2 2 0 1 1 4 0M33 9a2 2 0 1 1-4 0 2 2 0 1 1 4 0"/><path stroke-linecap="butt" d="M9 26c8.5-1.5 21-1.5 27 0l2-12-7 11V11l-5.5 13.5-3-15-3 15-5.5-14V25L7 14z"/><path stroke-linecap="butt" d="M9 26c0 2 1.5 2 2.5 4 1 1.5 1 1 .5 3.5-1.5 1-1.5 2.5-1.5 2.5-1.5 1.5.5 2.5.5 2.5 6.5 1 16.5 1 23 0 0 0 1.5-1 0-2.5 0 0 .5-1.5-1-2.5-.5-2.5-.5-2 .5-3.5 1-2 2.5-2 2.5-4-8.5-1.5-18.5-1.5-27 0z"/><path fill="none" d="M11.5 30c3.5-1 18.5-1 22 0M12 33.5c6-1 15-1 21 0"/></g></svg>

After

Width:  |  Height:  |  Size: 772 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45"><g fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path stroke-linecap="butt" d="M9 39h27v-3H9zm3-3v-4h21v4zm-1-22V9h4v2h5V9h5v2h5V9h4v5"/><path d="m34 14-3 3H14l-3-3"/><path stroke-linecap="butt" stroke-linejoin="miter" d="M31 17v12.5H14V17"/><path d="m31 29.5 1.5 2.5h-20l1.5-2.5"/><path fill="none" stroke-linejoin="miter" d="M11 14h23"/></g></svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useWebSocket } from "../lib/useWebSocket";
import { gameUIRegistry } from "./registry";
import { ChevronLeft } from "lucide-react";
// Mirrors RoomSummary in api/src/games/types.ts — keep in sync
interface RoomSummary {
@@ -15,12 +16,27 @@ interface RoomSummary {
status: "waiting" | "playing" | "finished";
}
const CHESS_TIME_CONTROLS = [
{ key: "bullet_1_0", label: "1+0", category: "Bullet" },
{ key: "bullet_2_1", label: "2+1", category: "Bullet" },
{ key: "blitz_3_0", label: "3+0", category: "Blitz" },
{ key: "blitz_3_2", label: "3+2", category: "Blitz" },
{ key: "blitz_5_0", label: "5+0", category: "Blitz" },
{ key: "blitz_5_3", label: "5+3", category: "Blitz" },
{ key: "rapid_10_0", label: "10+0", category: "Rapid" },
{ key: "rapid_15_10", label: "15+10", category: "Rapid" },
{ key: "classical_30_0", label: "30+0", category: "Classical" },
{ key: "none", label: "None", category: "No Clock" },
] as const;
export function GameLobby() {
const { send, subscribe, connected } = useWebSocket();
const navigate = useNavigate();
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [filter, setFilter] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [configGame, setConfigGame] = useState<string | null>(null);
const [chessTimeControl, setChessTimeControl] = useState("blitz_5_3");
const gameTypes = gameUIRegistry.list();
@@ -42,11 +58,21 @@ export function GameLobby() {
const filteredRooms = filter ? rooms.filter(r => r.gameSlug === filter) : rooms;
const activeRooms = filteredRooms.filter(r => r.status !== "finished");
function createRoom(gameSlug: string) {
function handleGameSelect(gameSlug: string) {
if (gameSlug === "chess") {
setConfigGame("chess");
return;
}
send({ type: "CREATE_ROOM", gameType: gameSlug });
setShowCreate(false);
}
function createChessRoom() {
send({ type: "CREATE_ROOM", gameType: "chess", options: { timeControl: chessTimeControl } });
setShowCreate(false);
setConfigGame(null);
}
return (
<div>
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
@@ -136,23 +162,73 @@ export function GameLobby() {
</div>
{showCreate && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50" onClick={() => { setShowCreate(false); setConfigGame(null); }}>
<div className="bg-surface-container-highest rounded-xl p-6 w-full sm:max-w-sm shadow-[0_20px_40px_rgba(0,0,0,0.5)]" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
<div className="space-y-2">
{gameTypes.map(g => (
{configGame === "chess" ? (
<>
<button
key={g.slug}
onClick={() => createRoom(g.slug)}
className="w-full flex items-center gap-3 rounded-xl bg-raised px-4 py-3 text-sm font-medium hover:bg-surface-container-high transition-colors"
onClick={() => setConfigGame(null)}
className="flex items-center gap-1 text-xs text-text-tertiary hover:text-foreground transition-colors mb-3"
>
<span className="text-lg">{g.icon}</span>
<span>{g.name}</span>
<ChevronLeft className="w-3.5 h-3.5" /> Back
</button>
))}
</div>
<h2 className="font-display text-base font-semibold mb-1">{"\u265A"} Chess</h2>
<p className="text-xs text-text-tertiary mb-4">Choose your time control</p>
<div className="space-y-3">
{["Bullet", "Blitz", "Rapid", "Classical", "No Clock"].map(category => {
const controls = CHESS_TIME_CONTROLS.filter(tc => tc.category === category);
return (
<div key={category}>
<div className="text-[10px] font-label font-semibold text-text-disabled uppercase tracking-wider mb-1.5">
{category}
</div>
<div className="flex flex-wrap gap-1.5">
{controls.map(tc => (
<button
key={tc.key}
onClick={() => setChessTimeControl(tc.key)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
chessTimeControl === tc.key
? "bg-primary/15 text-primary ring-1 ring-primary/30"
: "bg-raised text-text-tertiary hover:text-foreground"
}`}
>
{tc.label}
</button>
))}
</div>
</div>
);
})}
</div>
<button
onClick={createChessRoom}
className="mt-5 w-full rounded-xl bg-primary text-on-primary px-4 py-2.5 text-sm font-label font-semibold hover:opacity-90 transition-colors"
>
Create Room
</button>
</>
) : (
<>
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
<div className="space-y-2">
{gameTypes.map(g => (
<button
key={g.slug}
onClick={() => handleGameSelect(g.slug)}
className="w-full flex items-center gap-3 rounded-xl bg-raised px-4 py-3 text-sm font-medium hover:bg-surface-container-high transition-colors"
>
<span className="text-lg">{g.icon}</span>
<span>{g.name}</span>
</button>
))}
</div>
</>
)}
<button
onClick={() => setShowCreate(false)}
onClick={() => { setShowCreate(false); setConfigGame(null); }}
className="mt-4 w-full text-center text-sm text-text-tertiary hover:text-foreground transition-colors"
>
Cancel

View File

@@ -0,0 +1,544 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import type { Square } from "chess.js";
import type { GameUIProps } from "../registry";
import { Flag, Handshake, X, Check, Clock } from "lucide-react";
import type { PieceRenderObject } from "react-chessboard";
// ── Piece images (cburnett set from Lichess, CC BY-SA) ──
const PIECE_KEYS = ["wP","wR","wN","wB","wQ","wK","bP","bR","bN","bB","bQ","bK"] as const;
const chessPieces: PieceRenderObject = Object.fromEntries(
PIECE_KEYS.map(key => [
key,
(props?: { svgStyle?: React.CSSProperties }) => (
<img
src={`/pieces/${key}.svg`}
alt={key}
style={{ width: "100%", height: "100%", ...props?.svgStyle }}
draggable={false}
/>
),
])
);
/** Returns the image URL for a piece key like "wQ" or "bN" */
function pieceImgSrc(color: "white" | "black", type: string): string {
return `/pieces/${color === "white" ? "w" : "b"}${type.toUpperCase()}.svg`;
}
// ── Types matching server ChessPlayerView / ChessSpectatorView ──
interface ChessClockView {
white: number;
black: number;
increment: number;
activeColor: "white" | "black" | null;
}
interface ChessViewBase {
fen: string;
pgn: string;
turn: "white" | "black";
clock: ChessClockView | null;
drawOffer: "white" | "black" | null;
result: "white" | "black" | "draw" | null;
resultReason: string | null;
moveHistory: { from: string; to: string; san: string; color: "w" | "b" }[];
isCheck: boolean;
}
interface PlayerView extends ChessViewBase {
myColor: "white" | "black";
legalMoves: { from: string; to: string; promotion?: string }[];
}
interface SpectatorView extends ChessViewBase {
players: { white: string; black: string };
}
function isPlayerView(state: unknown): state is PlayerView {
return typeof state === "object" && state !== null && "myColor" in state;
}
// ── Clock Display ──
function formatTime(ms: number): string {
if (ms <= 0) return "0:00";
const totalSeconds = Math.ceil(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function ChessClock({ time, isActive, isLow, label }: {
time: number;
isActive: boolean;
isLow: boolean;
label: string;
}) {
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg font-mono text-lg transition-colors ${
isActive
? isLow
? "bg-destructive/15 text-destructive"
: "bg-primary/15 text-primary"
: "bg-card text-text-tertiary"
}`}>
<Clock className="w-4 h-4 opacity-60" />
<span className="font-semibold tabular-nums">{formatTime(time)}</span>
<span className="text-xs opacity-50 font-label">{label}</span>
</div>
);
}
// ── Move History ──
function MoveHistory({ moves, containerRef }: {
moves: { san: string; color: "w" | "b" }[];
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const pairs: { number: number; white?: string; black?: string }[] = [];
for (let i = 0; i < moves.length; i++) {
const moveNum = Math.floor(i / 2) + 1;
if (i % 2 === 0) {
pairs.push({ number: moveNum, white: moves[i]!.san });
} else {
const pair = pairs[pairs.length - 1];
if (pair) pair.black = moves[i]!.san;
}
}
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [moves.length]);
if (pairs.length === 0) {
return (
<div className="text-xs text-text-disabled text-center py-4">
No moves yet
</div>
);
}
return (
<div className="grid grid-cols-[auto_1fr_1fr] gap-x-2 gap-y-0.5 text-sm">
{pairs.map(p => (
<div key={p.number} className="contents">
<span className="text-text-disabled text-xs tabular-nums pr-1">{p.number}.</span>
<span className={`font-mono ${p.white ? "text-foreground" : ""}`}>{p.white ?? ""}</span>
<span className={`font-mono ${p.black ? "text-foreground" : ""}`}>{p.black ?? ""}</span>
</div>
))}
</div>
);
}
// ── Main Component ──
export function ChessGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
const view = state as PlayerView | SpectatorView;
const playerView = isPlayerView(state) ? state as PlayerView : null;
const myColor = playerView?.myColor ?? "white";
const boardOrientation = isSpectator ? "white" : myColor;
const isMyTurn = playerView ? view.turn === playerView.myColor : false;
const isGameOver = view.result !== null;
const moveHistoryRef = useRef<HTMLDivElement>(null);
// Selected square for click-to-move
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
const [promotionSquare, setPromotionSquare] = useState<{ from: Square; to: Square } | null>(null);
// Live clock state (client-side countdown for smooth display)
const [liveClock, setLiveClock] = useState(view.clock);
const lastServerClock = useRef(view.clock);
// Sync from server
useEffect(() => {
lastServerClock.current = view.clock;
setLiveClock(view.clock);
}, [view.clock?.white, view.clock?.black, view.clock?.activeColor]);
// Client-side clock tick (100ms intervals for smooth countdown)
useEffect(() => {
if (!liveClock || !liveClock.activeColor || isGameOver) return;
const interval = setInterval(() => {
setLiveClock(prev => {
if (!prev || !prev.activeColor) return prev;
const active = prev.activeColor;
const remaining = Math.max(0, prev[active] - 100);
if (remaining <= 0 && !isSpectator && active !== myColor) {
// Opponent ran out — claim timeout
onAction({ type: "claim_timeout" });
}
return { ...prev, [active]: remaining };
});
}, 100);
return () => clearInterval(interval);
}, [liveClock?.activeColor, isGameOver, isSpectator, myColor, onAction]);
// Compute legal move squares for highlighting
const legalMovesForSquare = useMemo(() => {
if (!playerView || !selectedSquare || isGameOver || !isMyTurn) return [];
return playerView.legalMoves
.filter(m => m.from === selectedSquare)
.map(m => m.to);
}, [playerView, selectedSquare, isGameOver, isMyTurn]);
// Build custom square styles
const customSquareStyles = useMemo(() => {
const styles: Record<string, React.CSSProperties> = {};
// Highlight selected square
if (selectedSquare) {
styles[selectedSquare] = {
backgroundColor: "rgba(233, 195, 73, 0.35)",
};
}
// Highlight legal moves
for (const sq of legalMovesForSquare) {
// Check if a piece occupies the target (capture indicator)
const game = new Chess(view.fen);
const targetPiece = game.get(sq as Square);
styles[sq] = targetPiece
? {
background: "radial-gradient(circle, transparent 55%, rgba(233, 195, 73, 0.45) 55%)",
}
: {
background: "radial-gradient(circle, rgba(233, 195, 73, 0.35) 25%, transparent 25%)",
};
}
// Highlight last move
const lastMove = view.moveHistory.length > 0 ? view.moveHistory[view.moveHistory.length - 1] : undefined;
if (lastMove) {
styles[lastMove.from] = {
...styles[lastMove.from],
backgroundColor: "rgba(233, 195, 73, 0.15)",
};
styles[lastMove.to] = {
...styles[lastMove.to],
backgroundColor: "rgba(233, 195, 73, 0.2)",
};
}
// Highlight king in check
if (view.isCheck && !isGameOver) {
const game = new Chess(view.fen);
// Find the king of the side in check
const board = game.board();
for (const row of board) {
for (const piece of row) {
if (piece && piece.type === "k" && piece.color === (view.turn === "white" ? "w" : "b")) {
styles[piece.square] = {
...styles[piece.square],
background: "radial-gradient(circle, rgba(220, 38, 38, 0.7) 0%, rgba(220, 38, 38, 0.2) 60%, transparent 70%)",
};
}
}
}
}
return styles;
}, [selectedSquare, legalMovesForSquare, view.fen, view.moveHistory, view.isCheck, view.turn, isGameOver]);
// Handle square click (for click-to-move)
const onSquareClick = useCallback((square: Square) => {
if (isSpectator || isGameOver || !isMyTurn || !playerView) return;
// If clicking a legal move target, make the move
if (selectedSquare && legalMovesForSquare.includes(square)) {
// Check for promotion
const game = new Chess(view.fen);
const piece = game.get(selectedSquare);
const isPromotion = piece?.type === "p" &&
((piece.color === "w" && square[1] === "8") ||
(piece.color === "b" && square[1] === "1"));
if (isPromotion) {
setPromotionSquare({ from: selectedSquare, to: square });
} else {
onAction({ type: "move", from: selectedSquare, to: square });
}
setSelectedSquare(null);
return;
}
// Select a piece (must be our color)
const game = new Chess(view.fen);
const piece = game.get(square);
if (piece && piece.color === (myColor === "white" ? "w" : "b")) {
setSelectedSquare(square);
} else {
setSelectedSquare(null);
}
}, [isSpectator, isGameOver, isMyTurn, playerView, selectedSquare, legalMovesForSquare, view.fen, myColor, onAction]);
// Handle drag-and-drop (react-chessboard v5 API)
const onPieceDrop = useCallback(({ sourceSquare, targetSquare }: { piece: unknown; sourceSquare: string; targetSquare: string | null }): boolean => {
if (isSpectator || isGameOver || !isMyTurn || !playerView || !targetSquare) return false;
// Check if this is a legal move
const isLegal = playerView.legalMoves.some(m =>
m.from === sourceSquare && m.to === targetSquare
);
if (!isLegal) return false;
// Check for promotion
const game = new Chess(view.fen);
const sourcePiece = game.get(sourceSquare as Square);
const isPromotion = sourcePiece?.type === "p" &&
((sourcePiece.color === "w" && targetSquare[1] === "8") ||
(sourcePiece.color === "b" && targetSquare[1] === "1"));
if (isPromotion) {
setPromotionSquare({ from: sourceSquare as Square, to: targetSquare as Square });
return false; // Don't visually drop yet; wait for promotion choice
}
onAction({ type: "move", from: sourceSquare, to: targetSquare });
setSelectedSquare(null);
return true;
}, [isSpectator, isGameOver, isMyTurn, playerView, view.fen, onAction]);
// Handle promotion selection
const handlePromotion = useCallback((promoType: string) => {
if (!promotionSquare) return;
onAction({ type: "move", from: promotionSquare.from, to: promotionSquare.to, promotion: promoType });
setPromotionSquare(null);
}, [promotionSquare, onAction]);
// Only allow dragging own pieces when it's your turn (v5 API)
const canDragPiece = useCallback(({ piece }: { isSparePiece: boolean; piece: { pieceType: string }; square: string | null }): boolean => {
if (isSpectator || isGameOver || !isMyTurn) return false;
const pieceColor = piece.pieceType[0] === "w" ? "white" : "black";
return pieceColor === myColor;
}, [isSpectator, isGameOver, isMyTurn, myColor]);
// Clear selection when turn changes
useEffect(() => {
setSelectedSquare(null);
setPromotionSquare(null);
}, [view.turn]);
// Resolve player names
const getPlayerName = (color: "white" | "black") => {
if (isPlayerView(state)) {
const id = color === (state as PlayerView).myColor ? myPlayerId : players.find(p => p.discordId !== myPlayerId)?.discordId;
return players.find(p => p.discordId === id)?.username ?? (color === myColor ? "You" : "Opponent");
}
const spectatorState = state as SpectatorView;
return players.find(p => p.discordId === spectatorState.players[color])?.username ?? color;
};
// Determine opponent/bottom player for layout
const topColor: "white" | "black" = boardOrientation === "white" ? "black" : "white";
const bottomColor: "white" | "black" = boardOrientation;
// Draw offer UI for the receiving player
const showDrawOffer = playerView && view.drawOffer && view.drawOffer !== playerView.myColor && !isGameOver;
const showDrawButton = playerView && !view.drawOffer && !isGameOver;
const pendingDrawOffer = playerView && view.drawOffer === playerView.myColor;
return (
<div className="flex flex-col lg:flex-row gap-4 items-start">
{/* Board + Clocks Column */}
<div className="flex flex-col gap-2 w-full lg:w-auto">
{/* Top player info + clock */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-3 h-3 rounded-full shrink-0 ${topColor === "white" ? "bg-white" : "bg-zinc-800 ring-1 ring-zinc-600"}`} />
<span className={`text-sm font-medium truncate ${view.turn === topColor && !isGameOver ? "text-primary" : "text-text-secondary"}`}>
{getPlayerName(topColor)}
</span>
{view.turn === topColor && !isGameOver && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</div>
{liveClock && (
<ChessClock
time={liveClock[topColor]}
isActive={liveClock.activeColor === topColor}
isLow={liveClock[topColor] < 30_000 && liveClock.activeColor === topColor}
label={topColor === "white" ? "W" : "B"}
/>
)}
</div>
{/* Chess Board */}
<div className="relative rounded-xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.4)] w-full max-w-[min(100%,560px)]">
<Chessboard options={{
position: view.fen,
boardOrientation: boardOrientation,
pieces: chessPieces,
onPieceDrop,
onSquareClick: ({ square }: { piece: unknown; square: string }) => onSquareClick(square as Square),
canDragPiece,
squareStyles: customSquareStyles,
darkSquareStyle: { backgroundColor: "#2a3a5c" },
lightSquareStyle: { backgroundColor: "#c8cad6" },
boardStyle: { borderRadius: "0" },
dropSquareStyle: { boxShadow: "inset 0 0 1px 4px rgba(233, 195, 73, 0.6)" },
animationDurationInMs: 200,
allowDragging: !isSpectator && !isGameOver,
}} />
{/* Custom Promotion Dialog */}
{promotionSquare && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/50">
<div className="bg-surface-container-highest rounded-xl p-3 shadow-[0_20px_40px_rgba(0,0,0,0.5)]">
<div className="text-xs font-label text-text-tertiary mb-2 text-center">Promote to</div>
<div className="flex gap-1.5">
{["q", "r", "b", "n"].map(piece => (
<button
key={piece}
onClick={() => handlePromotion(piece)}
className="w-14 h-14 flex items-center justify-center rounded-lg bg-raised hover:bg-primary/20 transition-colors p-2"
>
<img
src={pieceImgSrc(myColor, piece)}
alt={piece}
className="w-full h-full"
draggable={false}
/>
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* Bottom player info + clock */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-3 h-3 rounded-full shrink-0 ${bottomColor === "white" ? "bg-white" : "bg-zinc-800 ring-1 ring-zinc-600"}`} />
<span className={`text-sm font-medium truncate ${view.turn === bottomColor && !isGameOver ? "text-primary" : "text-text-secondary"}`}>
{getPlayerName(bottomColor)}
{!isSpectator && " (You)"}
</span>
{view.turn === bottomColor && !isGameOver && (
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</div>
{liveClock && (
<ChessClock
time={liveClock[bottomColor]}
isActive={liveClock.activeColor === bottomColor}
isLow={liveClock[bottomColor] < 30_000 && liveClock.activeColor === bottomColor}
label={bottomColor === "white" ? "W" : "B"}
/>
)}
</div>
</div>
{/* Side Panel */}
<div className="flex flex-col gap-3 w-full lg:w-72 lg:min-h-[400px]">
{/* Game Status */}
{isGameOver && (
<div className={`rounded-xl px-4 py-3 ${
view.result === "draw"
? "bg-warning/10 border border-warning/20"
: "bg-primary/10 border border-primary/20"
}`}>
<div className={`text-sm font-display font-semibold ${
view.result === "draw" ? "text-warning" : "text-primary"
}`}>
{view.result === "draw"
? "Draw"
: `${getPlayerName(view.result!)} wins`}
</div>
<div className="text-xs text-text-tertiary mt-0.5">{view.resultReason}</div>
</div>
)}
{/* Turn Indicator */}
{!isGameOver && !isSpectator && (
<div className={`rounded-xl px-4 py-2.5 text-sm font-medium ${
isMyTurn
? "bg-success/10 text-success border border-success/20"
: "bg-card text-text-tertiary"
}`}>
{isMyTurn ? "Your turn" : "Waiting for opponent..."}
</div>
)}
{/* Draw Offer Banner */}
{showDrawOffer && (
<div className="rounded-xl bg-info/10 border border-info/20 px-4 py-3">
<div className="text-sm text-info font-medium mb-2">Draw offered</div>
<div className="flex gap-2">
<button
onClick={() => onAction({ type: "accept_draw" })}
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg bg-success/15 text-success px-3 py-1.5 text-xs font-semibold hover:bg-success/25 transition-colors"
>
<Check className="w-3.5 h-3.5" /> Accept
</button>
<button
onClick={() => onAction({ type: "decline_draw" })}
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg bg-destructive/15 text-destructive px-3 py-1.5 text-xs font-semibold hover:bg-destructive/25 transition-colors"
>
<X className="w-3.5 h-3.5" /> Decline
</button>
</div>
</div>
)}
{pendingDrawOffer && (
<div className="rounded-xl bg-info/10 border border-info/20 px-4 py-2.5 text-xs text-info">
Draw offer sent — waiting for response...
</div>
)}
{/* Action Buttons */}
{!isSpectator && !isGameOver && (
<div className="flex gap-2">
<button
onClick={() => onAction({ type: "resign" })}
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-raised px-3 py-2 text-xs font-label font-medium text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Flag className="w-3.5 h-3.5" /> Resign
</button>
{showDrawButton && (
<button
onClick={() => onAction({ type: "offer_draw" })}
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-raised px-3 py-2 text-xs font-label font-medium text-text-tertiary hover:text-info hover:bg-info/10 transition-colors"
>
<Handshake className="w-3.5 h-3.5" /> Draw
</button>
)}
</div>
)}
{/* Move History */}
<div className="rounded-xl bg-card flex flex-col flex-1 min-h-0">
<div className="px-4 py-2.5 text-xs font-label font-semibold text-text-tertiary border-b border-border">
Moves
</div>
<div ref={moveHistoryRef} className="px-4 py-2 overflow-y-auto max-h-60 lg:max-h-none lg:flex-1">
<MoveHistory moves={view.moveHistory} containerRef={moveHistoryRef} />
</div>
</div>
{/* Clock Info (if applicable) */}
{liveClock && liveClock.increment > 0 && (
<div className="text-[10px] text-text-disabled text-center">
+{liveClock.increment / 1000}s increment per move
</div>
)}
</div>
</div>
);
}

View File

@@ -32,3 +32,15 @@ export const gameUIRegistry = {
return Array.from(plugins.values());
},
};
// ── Register game UI plugins ──
import { ChessGame } from "./chess/ChessGame";
gameUIRegistry.register({
slug: "chess",
name: "Chess",
icon: "\u265A",
maxPlayers: 2,
component: ChessGame,
});