branch:
client.tsx
7875 bytesRaw
import { useAgent } from "agents/react";
import {
  ArrowsClockwiseIcon,
  ChartBarIcon,
  CircleIcon,
  GameControllerIcon,
  HandshakeIcon,
  XIcon
} from "@phosphor-icons/react";
import { Button, Surface, Switch, Text } from "@cloudflare/kumo";
import { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import type { TicTacToeState } from "./server";
import "./styles.css";

function App() {
  const [state, setState] = useState<TicTacToeState>({
    board: [
      [null, null, null],
      [null, null, null],
      [null, null, null]
    ],
    currentPlayer: "X",
    winner: null
  });
  const [gamesPlayed, setGamesPlayed] = useState(0);
  const [autoPlayEnabled, setAutoPlayEnabled] = useState(true);
  const [stats, setStats] = useState({
    draws: 0,
    oWins: 0,
    xWins: 0
  });

  const agent = useAgent<TicTacToeState>({
    agent: "tic-tac-toe",
    onStateUpdate: (newState) => {
      setState(newState);
    },
    prefix: "some/prefix"
  });

  const handleCellClick = useCallback(
    async (row: number, col: number) => {
      if (state.board[row][col] !== null || state.winner) return;
      try {
        await agent.call("makeMove", [[row, col], state.currentPlayer]);
      } catch (error) {
        console.error("Error making move:", error);
      }
    },
    [agent, state.board, state.winner, state.currentPlayer]
  );

  const handleNewGame = useCallback(async () => {
    try {
      await agent.call("clearBoard");
      setGamesPlayed((prev) => prev + 1);
    } catch (error) {
      console.error("Error clearing board:", error);
    }
  }, [agent]);

  // Make random move when new game starts
  useEffect(() => {
    const isBoardEmpty = state.board.every((row) =>
      row.every((cell) => cell === null)
    );

    if (isBoardEmpty && gamesPlayed > 0 && autoPlayEnabled) {
      const timer = setTimeout(() => {
        const row = Math.floor(Math.random() * 3);
        const col = Math.floor(Math.random() * 3);
        handleCellClick(row, col);
      }, 1000);

      return () => clearTimeout(timer);
    }
  }, [state.board, gamesPlayed, autoPlayEnabled, handleCellClick]);

  // Check for game over and start new game after delay
  useEffect(() => {
    const isGameOver =
      state.winner ||
      state.board.every((row) => row.every((cell) => cell !== null));

    if (isGameOver) {
      if (state.winner === "X") {
        setStats((prev) => ({ ...prev, xWins: prev.xWins + 1 }));
      } else if (state.winner === "O") {
        setStats((prev) => ({ ...prev, oWins: prev.oWins + 1 }));
      } else {
        setStats((prev) => ({ ...prev, draws: prev.draws + 1 }));
      }

      const timer = setTimeout(() => {
        handleNewGame();
      }, 3000);

      return () => clearTimeout(timer);
    }
  }, [state.winner, state.board, handleNewGame]);

  const renderCell = (row: number, col: number) => {
    const value = state.board[row][col];
    return (
      // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- game board cell
      <div
        className={`cell ${value ? "played" : ""}`}
        onClick={() => handleCellClick(row, col)}
        key={`${row}-${col}`}
      >
        {value === "X" && (
          <XIcon size={40} weight="bold" className="text-kumo-info" />
        )}
        {value === "O" && (
          <CircleIcon size={36} weight="bold" className="text-kumo-danger" />
        )}
      </div>
    );
  };

  const playerSymbol = (player: "X" | "O") =>
    player === "X" ? "\u2A09" : "\u25EF";

  const getGameStatus = () => {
    if (state.winner) {
      const isX = state.winner === "X";
      return (
        <Surface className="rounded-xl px-4 py-3 text-center ring ring-kumo-line">
          <Text size="lg">
            Winner:{" "}
            <span
              className={`font-bold ${isX ? "text-kumo-info" : "text-kumo-danger"}`}
            >
              {playerSymbol(state.winner)}
            </span>
            !
          </Text>
        </Surface>
      );
    }

    if (state.board.every((row) => row.every((cell) => cell !== null))) {
      return (
        <Surface className="rounded-xl px-4 py-3 text-center ring ring-kumo-line">
          <Text size="lg">Game Draw!</Text>
        </Surface>
      );
    }

    return (
      <Surface className="rounded-xl px-4 py-3 text-center ring ring-kumo-line">
        <Text size="lg">
          Current Player:{" "}
          <span
            className={`font-bold ${
              state.currentPlayer === "X"
                ? "text-kumo-info"
                : "text-kumo-danger"
            }`}
          >
            {playerSymbol(state.currentPlayer)}
          </span>
        </Text>
      </Surface>
    );
  };

  return (
    <div className="min-h-full flex items-center justify-center p-6">
      <Surface className="w-full max-w-md rounded-2xl p-8 ring ring-kumo-line">
        {/* Title */}
        <div className="flex items-center justify-center gap-3 mb-6">
          <GameControllerIcon
            size={32}
            weight="duotone"
            className="text-kumo-brand"
          />
          <Text variant="heading1">Tic Tac Toe</Text>
        </div>

        {/* Game status */}
        {getGameStatus()}

        {/* Board */}
        <div className="grid grid-cols-3 gap-3 my-6 max-w-xs mx-auto">
          {state.board.map((row, rowIndex) =>
            row.map((_cell, colIndex) => renderCell(rowIndex, colIndex))
          )}
        </div>

        {/* Stats */}
        <div className="grid grid-cols-3 gap-3 mb-6">
          <Surface className="rounded-xl p-4 text-center ring ring-kumo-line bg-blue-500/10">
            <div className="text-2xl font-semibold text-kumo-info">
              {stats.xWins}
            </div>
            <div className="flex items-center justify-center gap-1 text-sm text-kumo-secondary mt-1">
              <XIcon size={14} weight="bold" />
              Wins
            </div>
          </Surface>
          <Surface className="rounded-xl p-4 text-center ring ring-kumo-line bg-red-500/10">
            <div className="text-2xl font-semibold text-kumo-danger">
              {stats.oWins}
            </div>
            <div className="flex items-center justify-center gap-1 text-sm text-kumo-secondary mt-1">
              <CircleIcon size={14} weight="bold" />
              Wins
            </div>
          </Surface>
          <Surface className="rounded-xl p-4 text-center ring ring-kumo-line bg-kumo-brand/10">
            <div className="text-2xl font-semibold text-kumo-brand">
              {stats.draws}
            </div>
            <div className="flex items-center justify-center gap-1 text-sm text-kumo-secondary mt-1">
              <HandshakeIcon size={14} weight="bold" />
              Draws
            </div>
          </Surface>
        </div>

        {/* Controls */}
        <div className="space-y-3 mb-6">
          <Button
            onClick={handleNewGame}
            className="w-full justify-center"
            icon={<ArrowsClockwiseIcon size={16} />}
          >
            New Game
          </Button>

          <div className="rounded-lg bg-kumo-control px-4 h-[36px] flex items-center justify-center">
            <Switch
              checked={autoPlayEnabled}
              onCheckedChange={(checked) => setAutoPlayEnabled(checked)}
              label="Random First Move"
              controlFirst={false}
            />
          </div>
        </div>

        {/* Games counter */}
        <div className="flex items-center justify-center gap-2 text-sm text-kumo-secondary">
          <ChartBarIcon size={16} />
          Games played: {gamesPlayed}
        </div>
      </Surface>
    </div>
  );
}

const root = createRoot(document.getElementById("root")!);
root.render(<App />);