Example 03

Chess arena

Play an agent or let two providers play each other. Bring OpenAI, Gemini, Claude, or Ollama.

PatternProvider choice + human and agent participants
Resulthttp://localhost:8787/match/demo
View source
Guided build

Build it from an empty Ayjnt project

Follow the steps in order. Every source block below is the actual checked example file, so the tutorial and runnable project cannot quietly drift apart.

01

Scaffold and install

Create an empty harness and add only the dependencies this example needs.

terminal
bunx ayjnt new chess-harness --empty
cd chess-harness
bun install
bun add chess.js

# delete agents/alive after adding the files below
02

Own rules and model turns in the agent

The agent validates every move with chess.js and constrains provider output to the legal move list.

Create agents/match/agent.ts with this source:

agents/match/agent.ts
import { Agent, callable } from "ayjnt";
import { Chess } from "chess.js";

type Provider = "openai" | "gemini" | "claude" | "ollama";
export type PlayerConfig = {
  provider: Provider;
  apiKey?: string;
  instructions?: string;
  baseUrl?: string;
};
type State = {
  fen: string;
  history: string[];
  lastMove: string | null;
  status: string;
  thinking: "w" | "b" | null;
};

export default class MatchAgent extends Agent<State> {
  override initialState: State = {
    fen: new Chess().fen(),
    history: [],
    lastMove: null,
    status: "White to move",
    thinking: null,
  };

  @callable()
  async move(uci: string): Promise<{ ok: boolean; error?: string }> {
    if (this.state.thinking) return { ok: false, error: "An agent is thinking." };
    return this.applyMove(uci);
  }

  @callable()
  async askModel(
    side: "w" | "b",
    config: PlayerConfig,
  ): Promise<{ ok: boolean; move?: string; error?: string }> {
    const chess = new Chess(this.state.fen);
    if (chess.turn() !== side || chess.isGameOver() || this.state.thinking) {
      return { ok: false, error: "It is not that player's turn." };
    }
    this.setState({ ...this.state, thinking: side });
    try {
      const legal = chess.moves({ verbose: true }).map((move) =>
        `${move.from}${move.to}${move.promotion ?? ""}`
      );
      const prompt = [
        `You are playing ${side === "w" ? "White" : "Black"}.`,
        `Position (FEN): ${chess.fen()}`,
        `Legal moves: ${legal.join(", ")}`,
        "Choose exactly one legal move. Return only its UCI string.",
        config.instructions?.trim() || "",
      ].filter(Boolean).join("\n");
      const answer = await callProvider(config, prompt);
      const picked = legal.find((move) =>
        answer.toLowerCase().includes(move.toLowerCase())
      );
      if (!picked) throw new Error(`Provider did not return a legal move: ${answer.slice(0, 120)}`);
      this.setState({ ...this.state, thinking: null });
      const result = this.applyMove(picked);
      return { ...result, move: picked };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      this.setState({ ...this.state, thinking: null, status: message });
      return { ok: false, error: message };
    }
  }

  @callable()
  async reset(): Promise<void> {
    this.setState(this.initialState);
  }

  private applyMove(uci: string): { ok: boolean; error?: string } {
    const chess = new Chess(this.state.fen);
    try {
      const move = chess.move({
        from: uci.slice(0, 2),
        to: uci.slice(2, 4),
        promotion: uci[4] || "q",
      });
      if (!move) return { ok: false, error: "Illegal move." };
      const status = chess.isCheckmate()
        ? `Checkmate — ${chess.turn() === "w" ? "Black" : "White"} wins`
        : chess.isDraw()
          ? "Draw"
          : `${chess.turn() === "w" ? "White" : "Black"} to move${chess.inCheck() ? " — check" : ""}`;
      this.setState({
        fen: chess.fen(),
        history: chess.history(),
        lastMove: `${move.from}${move.to}`,
        status,
        thinking: null,
      });
      return { ok: true };
    } catch {
      return { ok: false, error: "Illegal move." };
    }
  }
}

async function callProvider(config: PlayerConfig, prompt: string): Promise<string> {
  const key = config.apiKey?.trim();
  if (config.provider !== "ollama" && !key) throw new Error("This provider needs an API key.");

  if (config.provider === "openai") {
    const response = await fetch("https://api.openai.com/v1/responses", {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify({ model: "gpt-5-mini", input: prompt }),
    });
    const json = await checkedJson(response) as { output_text?: string; output?: Array<{ content?: Array<{ text?: string }> }> };
    return json.output_text ?? json.output?.[0]?.content?.[0]?.text ?? "";
  }
  if (config.provider === "gemini") {
    const response = await fetch(
      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(key!)}`,
      { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) },
    );
    const json = await checkedJson(response) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> };
    return json.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
  }
  if (config.provider === "claude") {
    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: { "content-type": "application/json", "x-api-key": key!, "anthropic-version": "2023-06-01" },
      body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 30, messages: [{ role: "user", content: prompt }] }),
    });
    const json = await checkedJson(response) as { content?: Array<{ text?: string }> };
    return json.content?.[0]?.text ?? "";
  }
  const base = (config.baseUrl || "http://localhost:11434").replace(/\/$/, "");
  const response = await fetch(`${base}/api/chat`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ model: "qwen3:8b", stream: false, messages: [{ role: "user", content: prompt }] }),
  });
  const json = await checkedJson(response) as { message?: { content?: string } };
  return json.message?.content ?? "";
}

async function checkedJson(response: Response): Promise<unknown> {
  if (!response.ok) throw new Error(`Provider error ${response.status}: ${(await response.text()).slice(0, 200)}`);
  return response.json();
}
03

Create the arena

The UI supports human versus agent and agent versus agent, with provider keys kept in browser memory for the demo.

Create agents/match/app.tsx with this source:

agents/match/app.tsx
import { useEffect, useMemo, useState } from "react";
import { Chess } from "chess.js";
import { useAgent } from "@ayjnt/match";
import type { PlayerConfig } from "./agent";

const glyphs: Record<string, string> = {
  wp: "♙", wn: "♘", wb: "♗", wr: "♖", wq: "♕", wk: "♔",
  bp: "♟", bn: "♞", bb: "♝", br: "♜", bq: "♛", bk: "♚",
};
type Mode = "human-white" | "human-black" | "agents";

export default function Match() {
  const agent = useAgent();
  const [mode, setMode] = useState<Mode>("human-white");
  const [selected, setSelected] = useState<string | null>(null);
  const [white, setWhite] = useState<PlayerConfig>({ provider: "openai" });
  const [black, setBlack] = useState<PlayerConfig>({ provider: "gemini" });
  const state = agent.state;
  const chess = useMemo(() => new Chess(state?.fen), [state?.fen]);

  const humanSide = mode === "human-white" ? "w" : mode === "human-black" ? "b" : null;
  useEffect(() => {
    if (!state || state.thinking || chess.isGameOver() || chess.turn() === humanSide) return;
    const config = chess.turn() === "w" ? white : black;
    const timer = window.setTimeout(() => {
      void agent.call("askModel", [chess.turn(), config]);
    }, 500);
    return () => window.clearTimeout(timer);
  }, [agent, black, chess, humanSide, state, white]);

  if (!state) return <main style={styles.main}>Connecting…</main>;
  const board = chess.board().flat();
  const click = async (index: number) => {
    if (chess.turn() !== humanSide || state.thinking) return;
    const square = `${"abcdefgh"[index % 8]}${8 - Math.floor(index / 8)}`;
    if (!selected) {
      const piece = board[index];
      if (piece?.color === humanSide) setSelected(square);
      return;
    }
    await agent.call("move", [`${selected}${square}`]);
    setSelected(null);
  };

  return (
    <main style={styles.main}>
      <header style={styles.header}>
        <div><small style={styles.eyebrow}>CHESS ARENA</small><h1 style={styles.h1}>{agent.name}</h1></div>
        <select value={mode} onChange={(event) => setMode(event.target.value as Mode)} style={styles.select}>
          <option value="human-white">Play as White</option>
          <option value="human-black">Play as Black</option>
          <option value="agents">Two agents</option>
        </select>
      </header>
      <div style={styles.layout}>
        <section>
          <div style={styles.status}>{state.thinking ? `${state.thinking === "w" ? "White" : "Black"} is thinking…` : state.status}</div>
          <div style={styles.board}>
            {board.map((piece, index) => {
              const square = `${"abcdefgh"[index % 8]}${8 - Math.floor(index / 8)}`;
              return (
                <button key={square} onClick={() => click(index)} style={{
                  ...styles.square,
                  background: (Math.floor(index / 8) + index) % 2 ? "#5573a9" : "#e7e7df",
                  outline: selected === square ? "3px solid #ff9254" : "none",
                }}>
                  {piece ? glyphs[piece.color + piece.type] : ""}
                </button>
              );
            })}
          </div>
        </section>
        <aside style={styles.sidebar}>
          <Player title="White" value={white} onChange={setWhite} disabled={humanSide === "w"} />
          <Player title="Black" value={black} onChange={setBlack} disabled={humanSide === "b"} />
          <button onClick={() => agent.call("reset", [])} style={styles.reset}>New game</button>
          <ol style={styles.moves}>{state.history.map((move, index) => <li key={index}>{index + 1}. {move}</li>)}</ol>
        </aside>
      </div>
    </main>
  );
}

function Player({ title, value, onChange, disabled }: {
  title: string; value: PlayerConfig; onChange: (value: PlayerConfig) => void; disabled: boolean;
}) {
  return (
    <fieldset style={{ ...styles.player, opacity: disabled ? 0.45 : 1 }} disabled={disabled}>
      <legend>{title} {disabled ? "· human" : "· agent"}</legend>
      <select value={value.provider} onChange={(event) => onChange({ ...value, provider: event.target.value as PlayerConfig["provider"] })} style={styles.select}>
        <option value="openai">OpenAI</option><option value="gemini">Gemini</option>
        <option value="claude">Claude</option><option value="ollama">Ollama</option>
      </select>
      {value.provider === "ollama" ? (
        <input placeholder="http://localhost:11434" value={value.baseUrl ?? ""} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} style={styles.input} />
      ) : (
        <input type="password" placeholder={`${value.provider} API key`} value={value.apiKey ?? ""} onChange={(event) => onChange({ ...value, apiKey: event.target.value })} style={styles.input} />
      )}
      <textarea placeholder="Additional playing style…" value={value.instructions ?? ""} onChange={(event) => onChange({ ...value, instructions: event.target.value })} style={styles.textarea} />
    </fieldset>
  );
}

const styles = {
  main: { fontFamily: "system-ui, sans-serif", maxWidth: 1050, margin: "0 auto", padding: "30px 20px", color: "#172033" },
  header: { display: "flex", justifyContent: "space-between", alignItems: "end", marginBottom: 24 },
  eyebrow: { color: "#225dd8", fontFamily: "monospace", letterSpacing: "0.16em" },
  h1: { margin: "5px 0 0", fontSize: 32 },
  layout: { display: "grid", gridTemplateColumns: "minmax(420px, 640px) 1fr", gap: 25 },
  status: { minHeight: 24, color: "#697180", fontSize: 13 },
  board: { display: "grid", gridTemplateColumns: "repeat(8, 1fr)", aspectRatio: "1", border: "8px solid #172033" },
  square: { border: 0, display: "grid", placeItems: "center", fontSize: "clamp(28px, 5vw, 54px)", padding: 0, cursor: "pointer" },
  sidebar: { display: "grid", gap: 13, alignContent: "start" },
  player: { display: "grid", gap: 8, border: "1px solid #d8dce2", borderRadius: 10, padding: 14 },
  select: { padding: "9px 10px", borderRadius: 7, border: "1px solid #cfd4dc", background: "white" },
  input: { padding: 9, borderRadius: 7, border: "1px solid #cfd4dc" },
  textarea: { padding: 9, borderRadius: 7, border: "1px solid #cfd4dc", minHeight: 62, resize: "vertical" as const },
  reset: { padding: 10, background: "#172033", color: "white", border: 0, borderRadius: 8 },
  moves: { maxHeight: 160, overflow: "auto", columns: 2, fontFamily: "monospace", fontSize: 11, color: "#697180" },
};
04

Run the harness

terminal
bun run build
bun run dev

# open http://localhost:8787/match/demo

The build step generates bindings, migrations, client hooks, and environment types before the runtime starts.

Chess arena running in the browser
Expected browser result after completing the tutorial.

Project shape

agents/match/agent.tsagents/match/app.tsx

Verify your result

  1. Make one legal human move and confirm the board and history update.
  2. Choose a provider, add its key or Ollama URL, and request the next move.
  3. Switch both sides to agents to watch the same harness orchestrate them.

← Back to all examples