# Computer opponent

> POST /ai/turn, the backend route where a Workers AI model plays one turn of a turn-based game in character, choosing among the moves the game's own search offers, and how useOpponent in the turns kit calls it and falls back when it cannot.

A turn-based game made with the turns kit gets a computer opponent with a personality: it plays at easy, medium or hard and says a short line each turn. The game's own search on the device finds the moves worth playing; the backend's `POST /ai/turn` route asks a Workers AI model to choose one of them in character and write the line. The model only ever sees legal moves, so it cannot play an illegal one, and when there is no backend or the model does not answer, the search plays its own pick.

The client side (`useOpponent`, `createOpponent`, `TurnRules`, levels) is on [Turns](https://docs.moonspell.app/engine/turns.md). This page is the route and how the two meet.

## How useOpponent uses it

`useOpponent` takes the game's rules, a level, a persona and the backend URL. This follows how `examples/moon-chess/` sets it up:

```tsx
import { chessRules, GAME_BACKEND_URL, useOpponent } from '@/engine';

// Inside a component: the medium rival from Moon Chess, playing Black.
export function useVesper() {
  return useOpponent({
    rules: chessRules,
    game: 'chess',
    level: 'medium',
    persona: 'Vesper, a sly night fox who plays quick, tricky chess and teases the player when they slip, but always fairly.',
    side: 'Black',
    backendUrl: GAME_BACKEND_URL,
  });
}
```

Each `opponent.takeTurn(state, history)`:

1. Searches the position on the device (`searchMovesAsync`, so the board keeps animating), to the level's depth and time.
2. Keeps a shortlist of moves a player at that level would consider (`OPPONENT_LEVELS`: hard keeps 3 moves close to the best, easy 6 from a looser range), each with a note such as `+0.4` or `wins in 2`.
3. Sends the shortlist to `POST /ai/turn` through `requestTurn` (from `src/engine/net/turn.ts`), waiting up to 8 seconds by default (`timeoutMs`).
4. Plays the model's choice and returns its line as `say`, with `source: 'model'`. If there is no backend, the request fails, or the answer is not on the list, it plays the search's own pick from the same shortlist, with `say: ''`, `source: 'search'` and a `reason` ("no backend", "timed out", a status code).

The level decides how strong the moves on the shortlist are; the model only picks among them. So the opponent stays sharp offline, only silent.

## The route

`POST /ai/turn` is in the scaffold's `backend/src/index.ts`, with the logic in `backend/src/ai.ts`.

### Request

| Field | Type | Notes |
| --- | --- | --- |
| `playerId` | string | The device's secret player id (8 to 64 letters, digits, `-` or `_`). `requestTurn` adds it. |
| `game` | string | What is being played, for example `"chess"`. Up to 40 characters. |
| `persona` | string, optional | Who the opponent is: a name and a personality. Up to 400 characters. |
| `level` | string, optional | `easy`, `medium` or `hard` (default `medium` in the prompt). |
| `side` | string, optional | The side it plays, for example `"Black"`. |
| `position` | string | The position as text: the rules' `describe(state)`, or the list of options. Up to 1500 characters. |
| `history` | string array, optional | The game so far in the game's notation; the last 60 moves are kept. |
| `options` | array of `{ move, note? }` | 1 to 24 moves; `move` up to 24 characters, `note` up to 80; no move twice. |

The request body is at most 16 KB.

### Response

`200` with `{ move, say, model, ms }`: `move` is exactly one of the options, `say` is the line (no emoji, one line, at most 120 characters), `model` the model that answered, `ms` how long it took.

| Status | Meaning | What the game does |
| --- | --- | --- |
| 400 | A field is missing or too long; the error names it. | Plays its own move. |
| 413 | Body over 16 KB. | Plays its own move. |
| 429 | Over the limit: 30 turns per player and 120 per game a minute, counted in the `Presence` object (`allowTurn`). | Plays its own move. |
| 502 | No model answered with an option in time. | Plays its own move. |
| 503 | No model available here (local development without Workers AI). | Plays its own move. |

### What the Worker does

1. Checks and trims the request (`parseTurnRequest`).
2. Checks the rate limits in `Presence`.
3. Builds a system prompt from the game, persona, side and level (hard: play the best-rated option unless another is as good; medium: now and then a slightly weaker one; easy: natural-looking moves, often not the engine's favourite), and a user message with the position, the game so far, the player's last move and the options with their notes.
4. Calls the models in `TURN_MODELS` in order, `@cf/openai/gpt-oss-20b` (with `reasoning_effort: "low"`) and then `@cf/meta/llama-4-scout-17b-16e-instruct`, asking for JSON that matches a schema whose `move` is an enum of the options. The whole turn has a 7 second budget (`TURN_BUDGET_MS`).
5. Matches the answer to an option (exactly, or ignoring case, spaces and check marks), cleans the line, and returns it. An answer off the list moves on to the next model.

Workers AI is the `AI` binding in `wrangler.jsonc` (`"ai": { "binding": "AI" }`); there is no API key in the Worker or the game. Workers AI usage is billed to the Cloudflare account the Worker is deployed to. The scaffold's comments say both models run on the Workers free plan; check the current model list for your account.

## Local development

Workers AI has no local simulator. The scaffold's `npm run dev` is `wrangler dev --local`, which keeps every binding local, so `/ai/turn` answers 503 there and the smoke test skips its checks. Two ways to reach real models while developing:

- **The REST fallback in `ai.ts`.** When the binding cannot run, the Worker calls `<AI_DEV_BASE_URL>/accounts/<AI_DEV_ACCOUNT_ID>/ai/run/<model>` with `AI_DEV_TOKEN` as a bearer token. Put the three values in `backend/.dev.vars`, which is git-ignored and never deployed. On your own account that is Cloudflare's API base (`https://api.cloudflare.com/client/v4`), your account id, and an API token allowed to use Workers AI. Not tested for this page.
- **A remote AI binding.** Cloudflare's docs say that in `wrangler dev` without `--local`, the AI binding connects to the real models while the rest of the Worker runs locally. Run `npx wrangler dev` instead of `npm run dev`. Not tested for this page.

Either way the calls use your account's Workers AI and are billed like production calls.

After deploying, `node test/smoke.mjs <url> --read-only` checks that `/ai/turn` refuses a turn with no options, plays one of the options, and says a short line.

## A backend made before the opponent

A `backend/` copied before `/ai/turn` existed has no `src/ai.ts`. The **realtime-backend** skill lists the steps: copy `ai.ts` from the scaffold, append the "Computer opponent" section of the scaffold's `protocol.ts` to both protocol copies, copy the `allowTurn` method and its fields into `presence.ts` (same class, no migration), add the route in `index.ts`, add `"ai": { "binding": "AI" }` to `wrangler.jsonc`, then test and deploy.

## The same pattern for other AI characters

`backend/CLAUDE.md` has a recipe for any AI character route (an NPC who talks, hints, generated quests) on the same binding: strict output, a fallback model, a time budget and a per-player limit, with `/ai/turn` as the worked example. Keep prompts and keys in the Worker, never in the game's bundle.
