diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx
index e67e411..ac30162 100644
--- a/packages/chess/src/app/App.tsx
+++ b/packages/chess/src/app/App.tsx
@@ -1,11 +1,12 @@
import { Routes, Route } from 'react-router-dom'
+import { GameView } from '../ui/GameView'
export function App() {
return (
} />
- } />
+ } />
} />
} />
@@ -21,14 +22,6 @@ function Home() {
)
}
-function Game() {
- return (
-
- Game
-
- )
-}
-
function Rules() {
return (
diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts
new file mode 100644
index 0000000..5f37dde
--- /dev/null
+++ b/packages/chess/src/hooks/useChessEngine.ts
@@ -0,0 +1,52 @@
+import { useState, useRef, useCallback } from 'react';
+import { ChessEngine, type GameResult } from '../engine';
+import type { PieceType } from '../schema';
+
+export function useChessEngine() {
+ const engineRef = useRef(null);
+
+ // Force re-render state
+ const [tick, setTick] = useState(0);
+
+ // Initialize engine lazily
+ if (!engineRef.current) {
+ engineRef.current = new ChessEngine();
+ }
+
+ const engine = engineRef.current;
+
+ const getTurn = useCallback(() => {
+ return engine.getCurrentTurn();
+ }, [engine, tick]);
+
+ const getFacts = useCallback(() => {
+ return engine.session.allFacts();
+ }, [engine, tick]);
+
+ const getLegalMoves = useCallback(() => {
+ return engine.getAllLegalMoves();
+ }, [engine, tick]);
+
+ const getResult = useCallback(() => {
+ return engine.checkGameResult();
+ }, [engine, tick]);
+
+ const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
+ const move = engine.findMove(from, to, promoteTo);
+ if (move) {
+ const result = engine.applyMove(move, promoteTo);
+ setTick(t => t + 1); // trigger re-render
+ return result;
+ }
+ return null;
+ }, [engine]);
+
+ return {
+ engine,
+ turn: getTurn(),
+ facts: getFacts(),
+ legalMoves: getLegalMoves(),
+ result: getResult(),
+ applyMove,
+ };
+}
diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx
new file mode 100644
index 0000000..d479b6e
--- /dev/null
+++ b/packages/chess/src/ui/Board.tsx
@@ -0,0 +1,155 @@
+import { useState, useMemo } from 'react';
+import type { ChessFact, PieceColor, PieceType } from '../schema';
+import { squareToAlgebraic, squareOf, squareColor } from '../coord';
+import type { LegalMove } from '../rules/types';
+import { Piece } from './Piece';
+
+interface BoardProps {
+ facts: ChessFact[];
+ legalMoves: LegalMove[];
+ onMove: (from: number, to: number) => void;
+ turn: PieceColor;
+}
+
+interface PieceState {
+ id: number;
+ type: PieceType;
+ color: PieceColor;
+}
+
+export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
+ // Build pieces map: square -> { id, type, color }
+ const pieces = useMemo(() => {
+ const map = new Map();
+
+ // Group facts by entity ID
+ const entityFacts = new Map();
+
+ for (const fact of facts) {
+ if (typeof fact.id !== 'number' || fact.id === 0) continue; // Skip GAME_ENTITY
+
+ let ent = entityFacts.get(fact.id);
+ if (!ent) {
+ ent = {};
+ entityFacts.set(fact.id, ent);
+ }
+
+ if (fact.attr === 'Position') ent.pos = fact.value as number;
+ if (fact.attr === 'PieceType') ent.type = fact.value as PieceType;
+ if (fact.attr === 'Color') ent.color = fact.value as PieceColor;
+ }
+
+ // Populate the board map
+ for (const [id, ent] of entityFacts.entries()) {
+ if (ent.pos !== undefined && ent.type && ent.color) {
+ map.set(ent.pos, { id, type: ent.type, color: ent.color });
+ }
+ }
+
+ return map;
+ }, [facts]);
+
+ // Drag state
+ const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
+
+ // Compute highlighted squares based on current dragged piece
+ const highlightedSquares = useMemo(() => {
+ if (!draggedPiece) return new Set();
+
+ const targets = new Set();
+ for (const move of legalMoves) {
+ if (move.pieceId === draggedPiece.id) {
+ targets.add(move.to);
+ }
+ }
+ return targets;
+ }, [draggedPiece, legalMoves]);
+
+ // Handlers
+ const handleDragStart = (id: number, square: number) => {
+ const p = pieces.get(square);
+ if (p && p.color === turn) { // only drag pieces of current turn
+ setDraggedPiece({ id, square });
+ }
+ };
+
+ const handleDragEnd = () => {
+ setDraggedPiece(null);
+ };
+
+ const handleDragOver = (e: React.DragEvent, square: number) => {
+ if (!draggedPiece) return;
+ if (highlightedSquares.has(square)) {
+ e.preventDefault(); // allow drop
+ e.dataTransfer.dropEffect = 'move';
+ }
+ };
+
+ const handleDrop = (e: React.DragEvent, targetSquare: number) => {
+ e.preventDefault();
+ if (draggedPiece && highlightedSquares.has(targetSquare)) {
+ onMove(draggedPiece.square, targetSquare);
+ }
+ setDraggedPiece(null);
+ };
+
+ // Generate board squares (rank 7 down to 0, file 0 to 7)
+ const squares = [];
+ for (let r = 7; r >= 0; r--) {
+ for (let f = 0; f <= 7; f++) {
+ const sq = squareOf(f, r);
+ const isDark = squareColor(sq) === 'dark';
+ const algebraic = squareToAlgebraic(sq);
+ const isHighlighted = highlightedSquares.has(sq);
+
+ const piece = pieces.get(sq);
+
+ squares.push(
+ handleDragOver(e, sq)}
+ onDrop={(e) => handleDrop(e, sq)}
+ >
+ {isHighlighted && (
+
+ )}
+
+ {piece && (
+
+ )}
+
+ {/* Rank/File labels (optional but helpful for debug) */}
+ {f === 0 && (
+
+ {r + 1}
+
+ )}
+ {r === 0 && (
+
+ {String.fromCharCode(97 + f)}
+
+ )}
+
+ );
+ }
+ }
+
+ return (
+
+ {squares}
+
+ );
+}
diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx
new file mode 100644
index 0000000..3771401
--- /dev/null
+++ b/packages/chess/src/ui/GameView.tsx
@@ -0,0 +1,78 @@
+import { useChessEngine } from '../hooks/useChessEngine';
+import { Board } from './Board';
+
+import type { ChessFact, ChessAttrMap } from '../schema';
+
+interface GameViewProps {
+ onUndo?: () => void;
+}
+
+export function GameView({ onUndo }: GameViewProps) {
+ const { facts, legalMoves, turn, result, applyMove } = useChessEngine();
+
+ const handleMove = (from: number, to: number) => {
+ applyMove(from, to, 'queen');
+ };
+
+ const isGameOver = result !== 'ongoing';
+
+ return (
+
+
+
+ {/* Header/Info section */}
+
+
+ Chess
+
+
+
+
+
+
{turn === 'white' ? "White's turn" : "Black's turn"}
+
+
+
+ Undo
+
+
+
+
+ {/* Game Result Banner */}
+ {isGameOver && (
+
+ {result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
+
+ )}
+
+
+
+
[]}
+ legalMoves={legalMoves}
+ turn={turn}
+ onMove={handleMove}
+ />
+
+ {/* Overlay for game over to prevent further interaction visually */}
+ {isGameOver && (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx
new file mode 100644
index 0000000..3472a3f
--- /dev/null
+++ b/packages/chess/src/ui/Piece.tsx
@@ -0,0 +1,52 @@
+import type { PieceColor, PieceType } from '../schema';
+
+export interface PieceProps {
+ color: PieceColor;
+ type: PieceType;
+ pieceId: number;
+ square: number;
+ onDragStart: (pieceId: number, square: number) => void;
+ onDragEnd: () => void;
+}
+
+const PIECE_SYMBOLS: Record> = {
+ white: {
+ king: '♔',
+ queen: '♕',
+ rook: '♖',
+ bishop: '♗',
+ knight: '♘',
+ pawn: '♙',
+ },
+ black: {
+ king: '♚',
+ queen: '♛',
+ rook: '♜',
+ bishop: '♝',
+ knight: '♞',
+ pawn: '♟',
+ },
+};
+
+export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
+ const symbol = PIECE_SYMBOLS[color][type];
+
+ return (
+ {
+ // Set drag image (optional, but good for native dnd)
+ e.dataTransfer.effectAllowed = 'move';
+ // Need to set some data for Firefox to allow dragging
+ e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
+ onDragStart(pieceId, square);
+ }}
+ onDragEnd={onDragEnd}
+ className="flex items-center justify-center w-full h-full text-5xl cursor-grab active:cursor-grabbing select-none hover:bg-white/20 rounded"
+ >
+ {symbol}
+
+ );
+}