import { useEffect, useRef, useState } from "react";
import { useAgent } from "agents/react";
import { createRoot } from "react-dom/client";
import { Chess, type Square } from "chess.js";
import {
Chessboard,
type ChessboardOptions,
type PieceDropHandlerArgs
} from "react-chessboard";
import type { State as ServerState } from "./chess";
function usePlayerId() {
const [pid] = useState(() => {
const existing = localStorage.getItem("playerId");
if (existing) return existing;
const id = crypto.randomUUID();
localStorage.setItem("playerId", id);
return id;
});
return pid;
}
type JoinReply = {
ok: true;
role: "w" | "b" | "spectator";
state: ServerState;
};
function describeGameStatus(state: ServerState | null): string {
if (!state) return "Connecting to game...";
switch (state.status) {
case "waiting":
return "Waiting for players";
case "active":
return "In progress";
case "mate":
return state.winner === "w"
? "Checkmate · White wins"
: "Checkmate · Black wins";
case "draw":
return "Draw";
case "resigned":
return state.winner
? `${state.winner === "w" ? "White" : "Black"} wins by resignation`
: "Game ended by resignation";
default:
return state.status;
}
}
type PlayerSlotProps = {
label: string;
playerId?: string;
isCurrent: boolean;
};
function PlayerSlot({ label, playerId, isCurrent }: PlayerSlotProps) {
const connected = Boolean(playerId);
const highlight = isCurrent
? "rgba(37, 99, 235, 0.12)"
: connected
? "rgba(59, 130, 246, 0.1)"
: "rgba(15, 23, 42, 0.04)";
const border = isCurrent
? "1px solid rgba(37, 99, 235, 0.6)"
: "1px solid rgba(15, 23, 42, 0.12)";
return (
{label}
{connected ? (
<>
{isCurrent ? "You" : "Player"} · {playerId!.slice(-8)}
>
) : (
"Waiting for player"
)}
);
}
/** --------------------------
* Main App
* -------------------------- */
function App() {
const widgetGameId =
typeof window.openai?.widgetState?.gameId === "string" &&
`${window.openai?.widgetState?.gameId === "string"}`.trim()
? `${window.openai?.widgetState?.gameId}`.trim()
: "";
const playerId = usePlayerId();
const [gameId, setGameId] = useState(
widgetGameId ? widgetGameId : null
);
const [gameIdInput, setGameIdInput] = useState(widgetGameId);
const [menuError, setMenuError] = useState(null);
const gameRef = useRef(new Chess());
const [fen, setFen] = useState(gameRef.current.fen());
const [myColor, setMyColor] = useState<"w" | "b" | "spectator">("spectator");
const [pending, setPending] = useState(false);
const [serverState, setServerState] = useState(null);
const [joined, setJoined] = useState(false);
useEffect(() => {
if (!gameId && widgetGameId) {
setGameId(widgetGameId);
}
if (!gameIdInput && widgetGameId) {
setGameIdInput(widgetGameId);
}
}, [widgetGameId, gameId, gameIdInput]);
function resetLocalGame() {
gameRef.current.reset();
setFen(gameRef.current.fen());
setPending(false);
setMyColor("spectator");
setServerState(null);
}
const activeGameName = gameId ?? "__lobby__";
const host = window.HOST ?? "http://localhost:5174/";
const { stub } = useAgent({
host,
name: activeGameName,
agent: "chess",
onStateUpdate: (s) => {
if (!gameId) return;
gameRef.current.load(s.board);
setFen(s.board);
setServerState(s);
}
});
useEffect(() => {
if (!gameId || joined) return;
(async () => {
try {
const res = (await stub.join({
playerId,
preferred: "any"
})) as JoinReply;
if (!res?.ok) return;
setMyColor(res.role);
gameRef.current.load(res.state.board);
setFen(res.state.board);
setServerState(res.state);
setJoined(true);
} catch (error) {
console.error("Failed to join game", error);
}
})();
}, [playerId, gameId, stub, joined]);
async function handleStartNewGame() {
const newId = crypto.randomUUID();
await window.openai?.setWidgetState({ gameId: newId });
resetLocalGame();
setMenuError(null);
setGameIdInput(newId);
setGameId(newId);
}
async function handleJoinGame() {
const trimmed = gameIdInput.trim();
if (!trimmed) {
setMenuError("Enter a game ID to join.");
return;
}
resetLocalGame();
setMenuError(null);
await window.openai?.setWidgetState({ gameId: trimmed });
setGameId(trimmed);
}
// Trigger a message on the ChatGPT conversation to help with the current board state
const handleHelpClick = () => {
window.openai?.sendFollowUpMessage?.({
prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice as there are no tools for you to use.`
});
};
const handleResign = async () => {
await stub.resign();
};
// Local-then-server move with reconcile
function onPieceDrop({
sourceSquare,
targetSquare
}: PieceDropHandlerArgs): boolean {
if (!gameId || !sourceSquare || !targetSquare || pending) return false;
const game = gameRef.current;
// must be seated and your turn
if (myColor === "spectator") return false;
if (game.turn() !== myColor) return false;
// must be your piece
const piece = game.get(sourceSquare as Square);
if (!piece || piece.color !== myColor) return false;
const prevFen = game.fen();
try {
const local = game.move({
from: sourceSquare,
to: targetSquare,
promotion: "q"
});
if (!local) return false;
} catch {
return false;
}
const nextFen = game.fen();
setFen(nextFen);
setPending(true);
// reconcile with server
stub
.move({ from: sourceSquare, to: targetSquare, promotion: "q" }, prevFen)
.then((r: { ok: boolean; fen: string }) => {
if (!r.ok) {
// rollback to server position
game.load(r.fen);
setFen(r.fen);
}
})
.finally(() => setPending(false));
return true;
}
const chessboardOptions: ChessboardOptions = {
id: `pvp-${activeGameName}`,
position: fen,
onPieceDrop,
boardOrientation: myColor === "b" ? "black" : "white",
allowDragging: !pending && myColor !== "spectator"
};
const maxSize = window.openai?.maxHeight ?? 650;
const boardSize = Math.max(Math.min(maxSize - 120, 560), 320);
const statusText = describeGameStatus(serverState);
const whiteId = serverState?.players?.w;
const blackId = serverState?.players?.b;
return (
{!gameId ? (
Ready to play?
Start a new match to generate a shareable game code or join an
existing game by pasting its ID below.
{
setGameIdInput(event.target.value);
if (menuError) setMenuError(null);
}}
/>
{menuError ? (
{menuError}
) : null}
) : (
Game {gameId}
{statusText}
{myColor === "spectator"
? "You are watching as a spectator"
: playerId === serverState?.players[gameRef.current.turn()]
? "Your turn"
: `Waiting for ${myColor === "w" ? "Black" : "White"}`}
Players
{myColor === "spectator" ? (
You're observing for now. We'll seat you automatically if a
spot opens up.
) : null}
)}
);
}
const root = createRoot(document.getElementById("root")!);
root.render();