/AYjnt/
All examples
realtimegame2p

Chess game

Two-player chess with turn enforcement + legal-move validation server-side and a React board synced via useAgent. A study in constraining client-side mutations.

What you'll learn
  • Constraining client-side mutations: validate on the server, echo state
  • Seat claiming via connection id, freeing seats in `onClose`
  • When to auto-queen promote vs require a follow-up frame
01 step

Start from the blank scaffold (with UI)

Same starter as the blank template but with React, react-dom and matching @types preinstalled so your agent.ts can have an app.tsx next to it. The default project is a Counter agent — we'll replace it with the example's agent in the next steps.

~/my-agent-app
my-app/ (--with-ui scaffold)
agent.ts
app.tsx
package.json
tsconfig.json
02 step

Replace counter/ with match/

One DO per /match/<name>. Two seats (white, black), any number of spectators. Seats are bound to connection ids; they open up automatically when a player disconnects.

~/my-agent-app
03 step

agents/match/agent.ts — validation shape

Clients propose intents: `{ kind: "move", from: 52, to: 36 }`. The server validates (piece belongs to you, right turn, legal move) and only then mutates state. Everything about the game — board, turn, history, result — lives in DO state and syncs to every connected tab.

agents/match/agent.ts ts
import { Agent, type Connection, type WSMessage } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

type Piece = { color: "w" | "b"; type: "K" | "Q" | "R" | "B" | "N" | "P" };
type State = {
  board: (Piece | null)[];
  toMove: "w" | "b";
  white: string | null; black: string | null;
  whiteName: string | null; blackName: string | null;
  history: { from: number; to: number; san: string }[];
  result: "white" | "black" | "draw" | null;
};

export default class MatchAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = freshState();

  override async onConnect(conn: Connection) { conn.setState({ name: null }); }

  override async onMessage(conn: Connection, message: WSMessage) {
    if (typeof message !== "string") return;
    const frame = JSON.parse(message);
    if (frame.kind === "join") { /* bind seat by connection id */ }
    if (frame.kind === "move" && !this.state.result) {
      const side = sideOf(conn.id, this.state);
      if (!side || side !== this.state.toMove) return;                          // wrong turn
      const check = validateMove(this.state.board, frame.from, frame.to, side);
      if (!check.ok) return;                                                    // illegal
      const piece = this.state.board[frame.from]!;
      const capture = this.state.board[frame.to];
      const board = [...this.state.board];
      board[frame.to] = piece; board[frame.from] = null;
      this.setState({ ...this.state, board,
        toMove: side === "w" ? "b" : "w",
        history: [...this.state.history, { from: frame.from, to: frame.to, san: square(frame.to) }],
        result: capture?.type === "K" ? (side === "w" ? "white" : "black") : null });
    }
  }

  override async onClose(conn: Connection) { /* open the seat if this was a player */ }
  override async onRequest() { return Response.json({ instance: this.name, ...this.state }); }
}
function freshState(): State { /* 32 pieces in starting position */ return {} as State; }
function sideOf(id: string, s: State) { return id === s.white ? "w" : id === s.black ? "b" : null; }
function validateMove(board: any, from: number, to: number, side: "w"|"b") { return { ok: true }; }
function square(i: number) { return "abcdefgh"[i % 8] + String(8 - Math.floor(i / 8)); }
04 step

agents/match/app.tsx — click-to-move board

Click a piece to select, click a destination to move. The UI never mutates board state locally — it just sends `{ kind: "move", from, to }`. If the move is illegal, the server drops it silently and the UI shows no change.

agents/match/app.tsx tsx
import { useState } from "react";
import { useAgent } from "@ayjnt/match";

const GLYPH: Record<string, string> = { wK:"♔", wQ:"♕", wR:"♖", wB:"♗", wN:"♘", wP:"♙", bK:"♚", bQ:"♛", bR:"♜", bB:"♝", bN:"♞", bP:"♟" };

export default function Match() {
  const agent = useAgent();
  const [selected, setSelected] = useState<number | null>(null);
  const state = agent.state;
  if (!state) return null;

  const click = (i: number) => {
    if (selected === null) { if (state.board[i]) setSelected(i); return; }
    agent.send(JSON.stringify({ kind: "move", from: selected, to: i }));
    setSelected(null);
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(8, 40px)" }}>
      {state.board.map((p, i) => (
        <button key={i} onClick={() => click(i)}
          style={{ background: (Math.floor(i/8) + i%8) % 2 ? "#b58863" : "#f0d9b5",
                   outline: selected === i ? "2px solid gold" : "none" }}>
          {p ? GLYPH[p.color + p.type] : ""}
        </button>
      ))}
    </div>
  );
}
05 step

The "constrain mutations" pattern

This is the same pattern as the space-game but with discrete moves instead of continuous physics. Works for anything turn-based: card games, grid-based strategy, tic-tac-toe.

pattern.ts ts
// client: here's what I want to do
//   agent.send({ kind: "move", from: 52, to: 36 })
//
// server: I decide whether it's legal
//   sideOfConnection(conn.id) === state.toMove ?  ──── wrong turn? drop.
//   validateMove(board, from, to, side).ok     ?  ──── illegal? drop.
//   this.setState({ board: ..., toMove: ..., history: ... })
//
// client: re-render from the new state
//   state.board is the source of truth
//   useAgent hook causes React to re-render on each CF_AGENT_STATE frame
06 step

Run + play

Open /match/saturday in two tabs with different names. Click ♔ in tab 1 to claim white; click ♚ in tab 2 to claim black. Spectators can connect to the same URL and watch without moving.

~/my-agent-app
07 step

What it looks like

White moves first. Your turn badge highlights. Move history piles up below. Game ends when a king gets captured (simplified win condition — no checkmate detection).

/match/saturday — white to move result
  match — saturday
  ♔ white: alice ← your move     ♚ black: bob

  white to move — your move

    a b c d e f g h
   ┌───────────────┐
  8│ ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜│
  7│ ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟│
  6│ . . . . . . . .│
  5│ . . . . . . . .│
  4│ . . . . ♙ . . .│    ← selected: e4
  3│ . . . . . . . .│
  2│ ♙ ♙ ♙ ♙ . ♙ ♙ ♙│
  1│ ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖│
   └───────────────┘

  history:  1. e4
08 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app