Skip to main content

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):

  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​

FieldTypeNotes
playerIdstringThe device's secret player id (8 to 64 letters, digits, - or _). requestTurn adds it.
gamestringWhat is being played, for example "chess". Up to 40 characters.
personastring, optionalWho the opponent is: a name and a personality. Up to 400 characters.
levelstring, optionaleasy, medium or hard (default medium in the prompt).
sidestring, optionalThe side it plays, for example "Black".
positionstringThe position as text: the rules' describe(state), or the list of options. Up to 1500 characters.
historystring array, optionalThe game so far in the game's notation; the last 60 moves are kept.
optionsarray 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.

StatusMeaningWhat the game does
400A field is missing or too long; the error names it.Plays its own move.
413Body over 16 KB.Plays its own move.
429Over the limit: 30 turns per player and 120 per game a minute, counted in the Presence object (allowTurn).Plays its own move.
502No model answered with an option in time.Plays its own move.
503No 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.