Computer opponent
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. 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:
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):
- Searches the position on the device (
searchMovesAsync, so the board keeps animating), to the level's depth and time. - 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.4orwins in 2. - Sends the shortlist to
POST /ai/turnthroughrequestTurn(fromsrc/engine/net/turn.ts), waiting up to 8 seconds by default (timeoutMs). - Plays the model's choice and returns its line as
say, withsource: '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, withsay: '',source: 'search'and areason("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
- Checks and trims the request (
parseTurnRequest). - Checks the rate limits in
Presence. - 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.
- Calls the models in
TURN_MODELSin order,@cf/openai/gpt-oss-20b(withreasoning_effort: "low") and then@cf/meta/llama-4-scout-17b-16e-instruct, asking for JSON that matches a schema whosemoveis an enum of the options. The whole turn has a 7 second budget (TURN_BUDGET_MS). - 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>withAI_DEV_TOKENas a bearer token. Put the three values inbackend/.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 devwithout--local, the AI binding connects to the real models while the rest of the Worker runs locally. Runnpx wrangler devinstead ofnpm 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.