# Bots and AI

> The bots and ai globals a table script receives - register the code that plays a bot, search with a rules kernel, and run a game's own AI model on the host.

- Source: https://diceytable.com/docs/scripting-api/bots-and-ai
- Last verified: 2026-10-02

A **game bot** is an opponent that holds a seat: it is in the turn order, it appears in `world.getPlayers()`
with `isBot: true`, and when its turn comes the table calls code your scene script registered for it. Two
globals carry the whole feature, alongside `world` and `globalEvents`:

- **`bots`** — register the handler that plays a bot (`bots.register`), see which bots are seated
  (`bots.list`), and give up a bot's turn on purpose (`bots.skipTurn`).
- **`ai`** — search a position with your game's rules (`ai.mcts`), run one of your game's declared ONNX models
  directly (`ai.evaluate`), and read what the host's device can do (`ai.tier`).

The manifest says which bots a game offers ([`bots`](https://diceytable.com/docs/mods/manifest/reference#bots)) and which models it
ships ([`aiModels`](https://diceytable.com/docs/mods/manifest/reference#aimodels)); the scene script says how each one plays.
[Game bots](https://diceytable.com/docs/mods/concepts/bots) is the concept page — read it first if you have not built a bot before.

## The three facts every entry on this page depends on

**Everything here runs on the host.** Table scripts only ever run on the host peer, so a bot's turn is played
there and its moves are ordinary script calls (`world.spawnObject`, `handle.setPosition`, `handle.destroy`)
that reach everyone else as table changes. A model's numbers never leave the host and are never saved.

**The table ends a bot's turn when `onTurn` returns.** There is no "end turn" call. A handler that resolves has
played its move; one that throws or runs out of time has its turn skipped with a notice; three failures in a
row pause the bot. `bots.skipTurn` is how a bot passes on purpose, and it is not counted as a failure.

**A rules kernel is the bridge between your table and a search.** `ai.mcts` does not look at the table. It
searches plain data that your script builds from the table, using a [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) you write —
pure functions for your game's rules. That is why a game whose outcome is physical (rolled dice, flicked
discs) has no kernel, and its bots are scripted by hand.

## Where to start

- A **scripted** bot with no model: [`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister) has a complete one.
- A scripted bot that searches: write a [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) with a `heuristic`, then call
  [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) **without** a `modelId`. The measured result is that this alone plays strongly.
- An **AI** bot: the same search, with the `modelId` of a model your manifest declares — pass
  [`ctx.modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextmodelid), which is the model the bot's current difficulty plays with (a difficulty
  may name its own model, so Easy and Hard can be different networks). See
  [Training a bot model](https://diceytable.com/docs/mods/guides/training-bots) for where models come from.
- A bot that plays a **card game**: it needs a deck, and the platform does not deal it one. Ship your decklists in a
  [`gameDecks`](https://diceytable.com/docs/mods/manifest/reference#gamedecks) file, read it with
  [`world.readData`](https://diceytable.com/docs/scripting-api/world#worldreaddata), and build the bot's deck when it sits down — the deck
  the room's owner chose in an unranked lobby is [`PlayerInfo.deckId`](https://diceytable.com/docs/scripting-api/types#playerinfodeckid),
  and `null` means your script chooses.

`bots` and `ai` are read-only globals. A script written before they existed that declares its own top-level
`bots` or `ai` still loads — its own name simply hides the global for that script.

The mod surface has a much smaller version of this: [`api.ai.evaluate`](https://diceytable.com/docs/scripting-api/api#apiaievaluate)
and [`api.listBots`](https://diceytable.com/docs/scripting-api/api#apilistbots). A mod cannot register a bot or move one, and has no
`ai.mcts`.

## See also

- [Game bots](https://diceytable.com/docs/mods/concepts/bots) — scripted and AI bots, hardware tiers, host migration, hidden information.
- [Manifest ▸ Bots](https://diceytable.com/docs/editor/manifest/bots) — declaring bots and importing a model in the editor.
- [Training a bot model](https://diceytable.com/docs/mods/guides/training-bots) — from a rules kernel to an `.onnx` file.
- [Host authority](https://diceytable.com/docs/scripting/concepts/host-authority) — why a script, and therefore a bot, runs on one peer.
- [Types](https://diceytable.com/docs/scripting-api/types) — `PlayerInfo`, which carries `isBot`, `botId`, `difficultyId` and `deckId`.

## HardwareTier

_Surface A — table script · type_

What a device can do for a model-backed bot, weakest first: `"none"` cannot run a model,
`"cpu-basic"` and `"cpu-fast"` run one on the processor, `"gpu"` runs one on the graphics
card.

```ts
declare type HardwareTier = "none" | "cpu-basic" | "cpu-fast" | "gpu";
```

What a device can do for a model-backed bot, weakest first. The platform measures it with a short hardware
check — a fixed probe model run once on the processor and, where available, once on the graphics card.

| Tier | Means | Measured on |
|---|---|---|
| `"none"` | Cannot run a model: no WebAssembly SIMD, fewer than two cores, the check failed — or the device has not been measured. | — |
| `"cpu-basic"` | Runs a model on the processor, slowly. | WebAssembly SIMD and at least two cores. |
| `"cpu-fast"` | Runs a model on the processor at a useful speed. | At least four cores, the probe model in 2.5 ms or less on one thread, and (where the browser reports it) 4 GB of memory or more. |
| `"gpu"` | Runs a model on the graphics card. | A WebGPU adapter that ran the probe model and gave matching answers. |

### How, why and when to use it

In a script you meet it as the answer to [`ai.tier`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitier), to scale your effort. In a manifest, a model
declares the weakest tier that should host it as [`minTier`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodels)
(`none` is not allowed there). The lobby refuses to start a table seating an AI bot whose model the owner's
device is below; a promoted host below it pauses that bot.

### Gotchas

**Advisory and self-reported.** A device measures itself. A tier decides who may start or host an AI-bot game,
and grants nothing else: a client that claims more than it has gets a bot that cannot think on its own machine.

**The thresholds come from one fast desktop.** A phone that is mid-range today will usually land on `cpu-fast`;
low-end devices land on `cpu-basic`. Expect the bands to be revised as more devices are measured.

### See also

- [`ai.tier`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitier) — the host's tier, read from a script.
- [Game bots: hardware tiers](https://diceytable.com/docs/mods/concepts/bots#hardware-tiers) — what each tier can host, with measured numbers.

## BotAbortSignal

_Surface A — table script · interface · 3 members_

The part of an abort signal a bot can use: a flag to poll and an `"abort"` event.

Scripts have no DOM, so this is not the browser's own abort-signal type, but these three
members behave the same way.

The part of an abort signal a bot can use: a flag to poll ([`aborted`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalaborted)) and an
`"abort"` event to listen for. Every bot turn gets one, as [`ctx.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal).

### How, why and when to use it

It fires when the bot should stop thinking: the turn's time ran out, the host removed or paused the bot, or the
turn moved on without it. Pass it to [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) and [`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate), which stop on their
own; poll `aborted` in your own loops; check it once more before you make the move.

Table scripts compile with no browser library, so this is not the browser's `AbortSignal` type — but its three
members behave the same way, and code written against those three works unchanged.

### Gotchas

**It is cooperative — but a late move is refused.** Firing the signal does not stop your code. A handler that
never looks at it keeps running after its turn has been skipped, and anything it reports afterwards is ignored.
What it can no longer do is change the table: once its turn is over (timed out, aborted, or its `onTurn` already
finished), every world-changing call that turn's code makes — moving or spawning an entity, `world.broadcast`,
`world.setSavedData`, drawing or dealing a card, a container put/take, a menu item or text label,
`bots.skipTurn` — throws (or rejects) with an error named `AbortError` whose message starts `bot-turn-over`, and the
table is left as it was. Reads still work. Your other handlers are unaffected: only code started by that turn's
`onTurn` (including what it awaits, and timers it sets) is refused. Work another handler queued on the same promise
chain as the turn — a person's action waiting behind the bot's step, in a game that runs one action at a time — runs
after `onTurn` has settled, belongs to that handler, and is never refused.

**One signal per turn.** It never resets. The next turn brings a new one.

### See also

- [`BotTurnContext.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) — where you get one.
- [`MctsSpec.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsignal) and [`AiEvaluateOptions.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluateoptionssignal) — where to pass it.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`aborted`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalaborted) | `true` once the bot should stop thinking. | `boolean` |
| [`addEventListener(type: "abort", listener: () => void)`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignaladdeventlistener) | Run `listener` when the signal fires. | `void` |
| [`removeEventListener(type: "abort", listener: () => void)`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalremoveeventlistener) | Stop a listener added with `addEventListener`. | `void` |

### botabortsignal.aborted

```ts
readonly aborted: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

`true` once the bot should stop thinking.

`true` once the bot should stop thinking. It starts `false` and, once `true`, stays `true`.

#### How, why and when to use it

Check it in any loop that may run long, and **always once more just before you make the move**. When it is
`true` the table has already skipped this turn, and a move made now is refused with an `AbortError`
(`bot-turn-over`) — the table does not change.

#### Gotchas

**Reading it is free; noticing it needs a pause.** The flag is set when the host's message arrives, which can only
happen when your code hands the thread back — an `await`, or `world.wait`. A tight synchronous loop never sees
it change.

#### See also

- [`BotAbortSignal.addEventListener`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignaladdeventlistener) — be told instead of polling.

### botabortsignal.addEventListener

```ts
addEventListener(type: "abort", listener: () => void): void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Run `listener` when the signal fires.

Runs `listener` when the signal fires. The only event is `"abort"`, and it fires at most once.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `type` | `"abort"` | Any other value is ignored. |
| `listener` | `() => void` | Called with no arguments. Adding the same function twice adds it once. |

#### How, why and when to use it

When you would rather be told than poll: to flip a flag your search loop checks, to take down a "thinking…"
label, or to stop work of your own that holds no reference to the signal.

#### Example

```ts
// content/scripting-api/examples/botabortsignal.addEventListener.ts

// Scene script: a bot that keeps improving its answer until the table tells it
// to stop - then plays the best move it found so far.
//
// A turn has a time budget (30 seconds unless the game sets another). When it
// runs out, ctx.signal fires its "abort" event and aborted turns true. The
// work below only ever looks at a flag, so it stops at the next check.

bots.register({
  id: "patient",

  async onTurn(ctx) {
    let stopped = false;
    const stop = () => {
      stopped = true;
    };
    ctx.signal.addEventListener("abort", stop);

    let best = 0;
    try {
      for (let effort = 1; effort <= 8 && !stopped; effort += 1) {
        // Hand the thread back between steps, or the "abort" event could not
        // be delivered until the loop finished on its own.
        await world.wait(0);
        best = effort;
      }
    } finally {
      ctx.signal.removeEventListener("abort", stop);
    }

    if (ctx.signal.aborted) {
      // Out of time: the table has already skipped this turn. Do not move.
      return;
    }
    world.log(`patient settled on effort ${best}`);
  }
});
```

#### Gotchas

**Adding a listener after the signal fired does nothing.** It will not be called. Check
[`aborted`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalaborted) first if you might be late.

**A listener that throws is reported in the script console** and the remaining listeners still run.

#### See also

- [`BotAbortSignal.removeEventListener`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalremoveeventlistener) — clean up when the turn ends normally.
- [`BotAbortSignal.aborted`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignalaborted) — the flag the same event sets.

### botabortsignal.removeEventListener

```ts
removeEventListener(type: "abort", listener: () => void): void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Stop a listener added with `addEventListener`.

Stops a listener added with [`addEventListener`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignaladdeventlistener). Removing one that was never
added does nothing.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `type` | `"abort"` | Any other value is ignored. |
| `listener` | `() => void` | The same function object you added. |

#### How, why and when to use it

When the turn ends normally — the move is made, your work is done — remove what you added, typically in a
`finally`. Otherwise a listener stays attached to a signal that may still fire (the bot is removed later in the
same turn, say) and runs clean-up you already did.

#### Example

```ts
// content/scripting-api/examples/botabortsignal.removeEventListener.ts

// Scene script: show a "thinking..." label for as long as a bot is thinking,
// and take it down whichever way the turn ends.
//
// The listener is only needed while the turn runs. Remove it when the turn is
// over, so a signal that fires later does not reach a label you already took
// down for a different reason.

bots.register({
  id: "thinker",

  async onTurn(ctx) {
    const labelId = `thinking-${ctx.peerId}`;
    const clear = () => world.removeTextLabel(labelId);

    world.setTextLabel({ id: labelId, text: "thinking...", position: [0, 0, 2] });
    ctx.signal.addEventListener("abort", clear);
    try {
      await world.wait(1);
    } finally {
      ctx.signal.removeEventListener("abort", clear);
      clear();
    }
  }
});
```

#### Gotchas

**Pass the same function, not a copy.** An arrow function written out again is a different function and removes
nothing; keep the one you added in a variable.

#### See also

- [`BotAbortSignal.addEventListener`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignaladdeventlistener) — the other half.

## RulesKernel

_Surface A — table script · interface · 13 members_

A game's rules as pure functions over plain data: the one description a bot's search, the
trainer and your own script all share.

`S` (a state) and `M` (a move) must be plain JSON, and nothing here may touch `world`,
read a clock or roll a random number. A kernel knows nothing about the table: your script
builds an `S` from what is on the table and turns the chosen `M` into ordinary script calls.

Your game's rules as pure functions over plain data — the one description that [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts), the
training page and the trainer CLI all share. `S` is a state (a position, plus whatever the rules need to know
about it) and `M` is a move; both must be plain JSON. A kernel knows nothing about the table: your script builds an
`S` from what is on the table, asks the search for an `M`, and turns that `M` back into ordinary script calls.

### How, why and when to use it

Write one when you want a bot that searches rather than one that follows hand-written rules. The search plays
thousands of imagined games from the current position, and it can only do that through functions that answer
instantly and never touch the table: `legalMoves`, `apply`, `terminal`. Add a `heuristic` and the search is
already a strong player with no model at all. Add `encode` and a policy layout (`policySize`, `moveIndex`,
`moveAt`) and the same kernel can train a model and be searched with it.

**What "headless" means.** A kernel is pure game logic. A game whose outcomes come from physics — dice rolled on
the table, discs flicked, a tower knocked over — has no kernel, because the next position is not a function of the
current one and a move. Its bots are scripted by hand, reading the table after the physics settles.

**The authoring convention.** Declare it as `const kernel` in the outermost scope of a scene script, with **no**
`export` keyword (a scene script is not a module; `export` would stop it loading). The training page finds it by
that name and copies the declaration and the helpers it mentions out of your compiled script — so everything the
kernel uses must be pure: no `world`, `globalEvents`, `refObject`, `bots`, `ai` or `declareVariables`, no
`Math.random`, no clock. A helper whose *parameter* happens to share a name with an impure outer declaration is
refused too; rename one of them.

### Example

```ts
// content/scripting-api/examples/ruleskernel.ts

// Scene script: noughts and crosses as a rules kernel - the smallest complete
// one. A script cannot import, so the kernel lives in the script itself.
//
// Declare it as `const kernel` in the outermost scope of a scene script, with
// no `export`: that is the name the training page looks for, and it copies
// this declaration (and the helpers it uses) out of the script on its own. So
// the kernel and its helpers must be pure - no world, no globalEvents, no bots,
// no ai, no Math.random, no clock.

interface Board {
  /** cells[row * 3 + col]: 0 empty, 1 the first seat's mark, -1 the second seat's. */
  cells: number[];
  /** The seat to move. */
  toPlay: string;
  /** Both seats, first mover first. In the state because terminal() must name them. */
  seats: [string, string];
}

const LINES: ReadonlyArray<readonly [number, number, number]> = [
  [0, 1, 2], [3, 4, 5], [6, 7, 8],
  [0, 3, 6], [1, 4, 7], [2, 5, 8],
  [0, 4, 8], [2, 4, 6]
];

/** 1 or -1 when that mark has three in a row, else 0. */
function lineWinner(cells: readonly number[]): number {
  for (const [a, b, c] of LINES) {
    if (cells[a] !== 0 && cells[a] === cells[b] && cells[a] === cells[c]) {
      return cells[a];
    }
  }
  return 0;
}

function markToMove(board: Board): number {
  return board.toPlay === board.seats[0] ? 1 : -1;
}

const kernel: RulesKernel<Board, number> = {
  id: "noughts-and-crosses",
  // Bump this when the rules, encode() or the policy layout change.
  version: 1,

  initial(seats) {
    if (seats.length !== 2 || seats[0] === seats[1]) {
      throw new Error("Noughts and crosses needs exactly two different seats.");
    }
    return { cells: [0, 0, 0, 0, 0, 0, 0, 0, 0], toPlay: seats[0], seats: [seats[0], seats[1]] };
  },

  currentPlayer(board) {
    return board.toPlay;
  },

  legalMoves(board) {
    if (lineWinner(board.cells) !== 0) {
      return [];
    }
    return board.cells.flatMap((cell, index) => (cell === 0 ? [index] : []));
  },

  apply(board, cell) {
    const cells = board.cells.slice();
    cells[cell] = markToMove(board);
    const next = board.toPlay === board.seats[0] ? board.seats[1] : board.seats[0];
    return { cells, toPlay: next, seats: board.seats };
  },

  terminal(board) {
    const won = lineWinner(board.cells);
    if (won === 0 && board.cells.includes(0)) {
      return null;
    }
    return { value: { [board.seats[0]]: won, [board.seats[1]]: -won } };
  },

  // Three 3x3 planes, from the point of view of the side to move: its marks,
  // the other side's marks, and a constant plane saying which side it is.
  encoding: { planes: 3, height: 3, width: 3 },
  encode(board) {
    const own = markToMove(board);
    const planes = new Float32Array(27);
    board.cells.forEach((mark, index) => {
      if (mark === own) planes[index] = 1;
      else if (mark === -own) planes[9 + index] = 1;
      planes[18 + index] = own === 1 ? 1 : 0;
    });
    return planes;
  },

  // One policy slot per cell. The move IS the slot, so both maps are identity.
  policySize: 9,
  moveIndex(cell) {
    return cell;
  },
  moveAt(index) {
    return index;
  },

  // Lines still open to the mover minus lines still open to the other side.
  heuristic(board) {
    const own = markToMove(board);
    const won = lineWinner(board.cells);
    if (won !== 0) {
      return won === own ? 1 : -1;
    }
    let score = 0;
    for (const line of LINES) {
      const marks = line.map((index) => board.cells[index]);
      const mine = marks.filter((mark) => mark === own).length;
      const theirs = marks.filter((mark) => mark === -own).length;
      if (theirs === 0) score += mine;
      if (mine === 0) score -= theirs;
    }
    return Math.max(-1, Math.min(1, score / 8));
  }
};

world.log(`${kernel.id} v${kernel.version}: ${kernel.legalMoves(kernel.initial(["a", "b"])).length} opening moves`);
```

### Gotchas

**Pure means pure.** `apply` returns a new state and leaves its inputs alone; nothing reads a clock or rolls a
random number; the same call always gives the same answer. The trainer checks this before any self-play, and a
kernel that breaks it trains a model on garbage. At the table nothing checks — a misbehaving kernel just plays
badly.

**Bump `version` when the rules, `encode` or the policy layout change.** A model trained on version 1 reads the
board and the move slots the way version 1 wrote them.

**Two players is what the search is built for.** Terminal results are exact for any number of seats, but a value
from a model or `heuristic` is spread over the other seats as if they all shared the mover's loss. See
[`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts).

### See also

- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — searching with a kernel.
- [Training a bot model](https://diceytable.com/docs/mods/guides/training-bots) — what a kernel's `encode` and policy layout are for.
- [Game bots](https://diceytable.com/docs/mods/concepts/bots) — scripted versus AI bots.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`id`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelid) | Stable id of this rule set, e.g. `"reversi"`. Recorded in every training game. | `string` |
| [`version`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelversion) | Bump when the rules, `encode` or the policy layout change: old models are then stale. | `number` |
| [`initial(seats: string[], setup?: unknown)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelinitial) | The starting position for these seats, in turn order. | `S` |
| [`currentPlayer(s: S)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelcurrentplayer) | The seat to move. Only meaningful while `terminal` returns `null`. | `string` |
| [`legalMoves(s: S)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernellegalmoves) | Every legal move. NEVER empty for a position that is not over: a turn with nothing to do is an explicit pass move with its own policy index. | `M[]` |
| [`apply(s: S, m: M)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelapply) | The position after `m`. Returns a NEW state and leaves `s` and `m` untouched. | `S` |
| [`terminal(s: S)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelterminal) | `null` while the game is on; otherwise each seat's result in -1..1 (win 1, draw 0, loss -1). | `{ value: Record<string, number> } \| null` |
| [`encode(s: S)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencode) | The position as model input, seen by the player to move. Length = planes x height x width. | `Float32Array` |
| [`encoding`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencoding) | The shape of `encode`'s output: `planes` stacked `height` x `width` grids, row by row. | `{ planes: number; height: number; width: number }` |
| [`policySize`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelpolicysize) | Length of the policy vector: one slot per move the game can ever offer, pass included. | `number` |
| [`moveIndex(m: M)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex) | A move's slot in the policy vector, from 0 to `policySize - 1`. Different legal moves get different slots. | `number` |
| [`moveAt(i: number)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveat) | The move a policy slot stands for: `moveAt(moveIndex(m))` equals `m`. | `M` |
| [`heuristic(s: S)`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelheuristic) | Optional hand-written estimate in -1..1 for the player to move. `ai.mcts` falls back to it when no model can run. | `number` |

### ruleskernel.id

```ts
id: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Stable id of this rule set, e.g. `"reversi"`. Recorded in every training game.

A stable name for this rule set, such as `"reversi"`. It is written into every training game record.

#### How, why and when to use it

Pick it once and keep it. Training refuses to merge game records whose kernel ids differ, which is what stops
games of two different rule sets from being trained into one model.

#### Gotchas

**Renaming it orphans your records.** Change `version`, not `id`, when the rules change.

#### See also

- [`RulesKernel.version`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelversion) — what to change instead.

### ruleskernel.version

```ts
version: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Bump when the rules, `encode` or the policy layout change: old models are then stale.

The rule set's version number. Bump it whenever the rules, `encode` or the policy layout change.

#### How, why and when to use it

A model learns the board as `encode` wrote it and moves as `moveIndex` numbered them. Change either and an old
model reads nonsense. Bumping the version is how training knows old game records belong to a different kernel:
records from two versions are refused as one data set.

#### Gotchas

**Nothing at the table compares it.** `ai.mcts` will happily search a version-2 kernel with a model trained on
version 1. Re-train, or keep the old encoding, when you bump it.

#### See also

- [`RulesKernel.encode`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencode) and [`RulesKernel.moveIndex`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex) — the two layouts a model depends on.

### ruleskernel.initial

```ts
initial(seats: string[], setup?: unknown): S;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The starting position for these seats, in turn order.

`setup` is what your script passed to `match.start({ kernel, setup })` for THIS match
(two decklists, a shuffle seed, a board size). It is plain JSON, recorded with the match and
handed back unchanged to every replay of it: the host's check at `match.end`, each other
player's replay and a takeback rebuild. It is `undefined` for a match started without one
and always `undefined` in self-play training, so a kernel that needs it must cope (for
example by making up its own setup). Never change `setup`.

The starting position for these seats, given in turn order.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `seats` | `string[]` | Seat names in turn order. Training passes its own names; treat them as opaque strings. |
| `setup` | `unknown` | Optional. The `setup` your script passed to [`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart) for this match, unchanged. `undefined` without one, and always `undefined` in self-play training. |

#### Returns

`S` — a new starting state that carries everything the other members need, the seats included.

#### How, why and when to use it

Training calls it to start every self-play game. At the table you usually do not: your script builds the current
state from the table instead. Write it anyway — training cannot start without it.

A game whose starting position differs per match (two players' decklists, a shuffle seed, a board size) reads it
from `setup`. Every replay of a ranked match calls `initial(seats, setup)` with the setup the match was started
with, so every replay starts from the same position. The example on
[`match.setup`](https://diceytable.com/docs/scripting-api/matches#matchsetup) shuffles two decks from a seed this way.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.initial.ts

// Scene script: initial() for a race game for two to four players.
//
// The search and the trainer call initial() with the seats in turn order and
// nothing else. Whatever the other members need to know about the seats -
// who is who, who moves next - has to be IN the state, because a kernel
// method is handed the state and nothing more.

interface Race {
  /** Squares advanced, per seat. */
  progress: Record<string, number>;
  /** Turn order, as initial() was given it. */
  seats: string[];
  /** Index into seats of the seat to move. */
  toMove: number;
}

const initial: RulesKernel<Race, number>["initial"] = (seats) => {
  if (seats.length < 2 || seats.length > 4 || new Set(seats).size !== seats.length) {
    throw new Error("This race needs two to four different seats.");
  }
  const progress: Record<string, number> = {};
  for (const seat of seats) {
    progress[seat] = 0;
  }
  // Copy the array: the state must not share anything with the caller's input.
  return { progress, seats: [...seats], toMove: 0 };
};

world.log(JSON.stringify(initial(["north", "east", "south"])));
```

#### Gotchas

**Kernel methods only ever see the state.** If `terminal` has to name the seats in its result, the seats must be in
the state. Store a copy, not the caller's array.

**Refuse seat counts your game cannot play** by throwing; a kernel that throws is reported by the trainer's check
rather than producing broken games.

**Cope with no `setup`.** Self-play training and the trainer's kernel check never pass one. A kernel that needs a
setup should make a sensible one up (a default deck, seed 1) rather than throw. And never change `setup`: it is
the match's record, not scratch space.

#### See also

- [`RulesKernel.currentPlayer`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelcurrentplayer) — reading the seat to move back out of the state.

### ruleskernel.currentPlayer

```ts
currentPlayer(s: S): string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The seat to move. Only meaningful while `terminal` returns `null`.

The seat to move in this position. Only meaningful while [`terminal`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelterminal) returns `null`.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position. |

#### Returns

`string` — one of the seats the state was created with.

#### How, why and when to use it

The search credits every move to the seat this names for the position the move was played from, and reads model
and heuristic values as "for this seat". So turn order can be anything your game wants — extra turns, skipped
seats, simultaneous phases resolved in order — as long as this answer is right.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.currentPlayer.ts

// Scene script: currentPlayer() for a game where a capture earns another move.
//
// Nothing assumes turns alternate. The search credits every move to the seat
// currentPlayer() names for the position it was played from, so a seat that
// moves twice in a row is scored correctly - as long as this answer is right.

interface Captures {
  seats: [string, string];
  /** Index of the seat to move. apply() leaves it unchanged after a capture. */
  toMove: 0 | 1;
  stones: number[];
}

const currentPlayer: RulesKernel<Captures, number>["currentPlayer"] = (s) => s.seats[s.toMove];

const position: Captures = { seats: ["light", "dark"], toMove: 1, stones: [3, 0, 4] };
world.log(`to move: ${currentPlayer(position)}`);
```

#### Gotchas

**Name a seat that exists.** The trainer's check refuses a kernel whose `currentPlayer` returns a seat that is not
in the game.

#### See also

- [`RulesKernel.encode`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencode) — written from this seat's point of view.
- [`RulesKernel.heuristic`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelheuristic) — scored for this seat.

### ruleskernel.legalMoves

```ts
legalMoves(s: S): M[];
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Every legal move. NEVER empty for a position that is not over: a turn with nothing to do
is an explicit pass move with its own policy index.

Every legal move in this position.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position. |

#### Returns

`M[]` — **never empty for a position that is not over.** A turn with nothing to do is an explicit pass move with
its own policy slot. For a finished position the list is not consulted.

#### How, why and when to use it

This is the first thing the search asks of every position it visits, so it is the hottest function in your
kernel. Keep it allocation-light and free of anything the search does not need.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.legalMoves.ts

// Scene script: legalMoves() for a placement game in which a player with no
// placement must pass.
//
// The list is never empty for a position that is not over. "I have nothing to
// do" is a move like any other - PASS here - with its own policy slot, so the
// search can play it and a model can learn when it is forced.

const SIZE = 8;
const PASS = SIZE * SIZE;

interface Grid {
  cells: number[];
  toMove: 1 | -1;
}

/** Your own rule for whether `cell` is a legal placement for the side to move. */
function canPlace(grid: Grid, cell: number): boolean {
  const row = Math.floor(cell / SIZE);
  return grid.cells[cell] === 0 && (row === 0 || grid.cells[cell - SIZE] !== 0);
}

const legalMoves: RulesKernel<Grid, number>["legalMoves"] = (grid) => {
  const moves: number[] = [];
  for (let cell = 0; cell < PASS; cell += 1) {
    if (canPlace(grid, cell)) {
      moves.push(cell);
    }
  }
  // terminal() decides when the game is over; here, a full grid. Anything
  // short of that gets at least the pass.
  return moves.length > 0 ? moves : [PASS];
};

const empty: Grid = { cells: new Array<number>(PASS).fill(0), toMove: 1 };
world.log(`${legalMoves(empty).length} opening placements`);
```

#### Gotchas

**Empty means "this game is broken".** The search rejects a non-terminal position with no legal move, and the
trainer's check reports it as `no-legal-moves`. If your game can leave a player stuck, either make that a pass or
make it `terminal`.

**Two legal moves must not share a policy slot.** The trainer's check reports `move-index-duplicate`.

**Moves must survive JSON** unchanged — plain objects, strings and numbers. Training games are stored as JSON.

#### See also

- [`RulesKernel.moveIndex`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex) — every move here needs a slot.
- [`RulesKernel.terminal`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelterminal) — the other way a position can have no move.

### ruleskernel.apply

```ts
apply(s: S, m: M): S;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The position after `m`. Returns a NEW state and leaves `s` and `m` untouched.

The position after a move. Returns a **new** state and leaves both arguments untouched.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position before the move. |
| `m` | `M` | A move from `legalMoves(s)`. |

#### Returns

`S` — the position after `m`, with the seat to move updated.

#### How, why and when to use it

The search applies thousands of moves to shared states while it builds its tree. Copy what you change (an array
`slice()`, an object spread) and share what you do not.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.apply.ts

// Scene script: apply() for a pile game, written so that it never changes
// what it was given.
//
// The search keeps thousands of states alive at once and applies many moves
// to the SAME one. An apply() that edits its input in place corrupts every
// branch that shares it - the bot then plays nonsense with no error anywhere.
// The trainer's kernel check refuses a kernel that does this.

interface Piles {
  piles: number[];
  seats: [string, string];
  toMove: 0 | 1;
}

interface Take {
  pile: number;
  count: number;
}

const apply: RulesKernel<Piles, Take>["apply"] = (s, move) => {
  const piles = s.piles.slice(); // a copy - never `s.piles[move.pile] -= ...`
  piles[move.pile] -= move.count;
  return { piles, seats: s.seats, toMove: s.toMove === 0 ? 1 : 0 };
};

const before: Piles = { piles: [3, 4, 5], seats: ["a", "b"], toMove: 0 };
const after = apply(before, { pile: 2, count: 2 });
world.log(`${before.piles.join(",")} -> ${after.piles.join(",")}`);
```

#### Gotchas

**Mutating the input corrupts the search silently.** Every branch that shared the state now sees the move, and
the bot plays nonsense without an error. The trainer's check catches it (`apply-mutates-input`); the table does
not.

**Same state and move, same result.** No randomness, no clock — `apply-not-deterministic` otherwise. A game with a
random element (a shuffled draw) does not fit a kernel as-is.

**The result must survive JSON** unchanged: no `Map`, `Set`, class instance or function inside a state.

#### See also

- [`RulesKernel.currentPlayer`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelcurrentplayer) — who moves in the new state.

### ruleskernel.terminal

```ts
terminal(s: S): { value: Record<string, number> } | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

`null` while the game is on; otherwise each seat's result in -1..1 (win 1, draw 0, loss -1).

Whether the game is over, and if so how each seat did.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position. |

#### Returns

`{ value: Record<string, number> } | null` — `null` while the game is on; otherwise one entry **per seat** with a
result from -1 to 1: 1 a win, 0 a draw, -1 a loss, anything between for a placing.

#### How, why and when to use it

This is where the search gets its only exact answers, and where training gets the outcome every recorded
position learns from. It is exact for any number of seats, unlike a model's or heuristic's single value.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.terminal.ts

// Scene script: terminal() for a race to ten points, for any number of seats.
//
// null while the game is on. When it is over: every seat's result in -1..1 -
// 1 a win, 0 a draw, -1 a loss, and anything between for a placing. Name
// EVERY seat; a seat missing from the record has no result to learn from.

interface Score {
  points: Record<string, number>;
  seats: string[];
  toMove: number;
}

const TARGET = 10;

const terminal: RulesKernel<Score, number>["terminal"] = (s) => {
  const best = Math.max(...s.seats.map((seat) => s.points[seat] ?? 0));
  if (best < TARGET) {
    return null;
  }
  const leaders = s.seats.filter((seat) => (s.points[seat] ?? 0) === best);
  const value: Record<string, number> = {};
  for (const seat of s.seats) {
    // A shared first place is a draw between the leaders; everyone else lost.
    value[seat] = leaders.includes(seat) ? (leaders.length === 1 ? 1 : 0) : -1;
  }
  return { value };
};

world.log(JSON.stringify(terminal({ points: { a: 10, b: 7, c: 10 }, seats: ["a", "b", "c"], toMove: 1 })));
```

#### Gotchas

**Every seat, exactly once.** A result that leaves a seat out (or adds one) is reported by the trainer's check as
`terminal-value-seats`; a value outside -1..1 as `terminal-value-range`.

**A game must end.** Training cuts a game off at a ply cap and records it as a draw; the trainer's check reports a
kernel whose games never finish.

#### See also

- [`RulesKernel.legalMoves`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernellegalmoves) — never empty while this is `null`.

### ruleskernel.encode

```ts
encode(s: S): Float32Array;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The position as model input, seen by the player to move. Length = planes x height x width.

The position as model input: a `Float32Array` of exactly `planes × height × width` values, laid out plane by plane
and row by row, written from the point of view of the side to move.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position. |

#### Returns

`Float32Array` of length `encoding.planes × encoding.height × encoding.width`, every value finite.

#### How, why and when to use it

A model sees nothing else of the game. Planes are the usual layout — one for "my pieces", one for "their
pieces", perhaps one for "whose move" or "legal squares" — and the model the training tools build expects that
board-shaped input. Write it relative to the mover so one network learns to play both sides.

A kernel used only for model-less search still needs `encode` and `encoding`: the contract requires them, and
training needs them the day you want a model.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.encode.ts

// Scene script: encode() and its declared shape, for a 6 x 7 drop game.
//
// The model sees ONLY this array. Its length must be exactly
// planes x height x width from `encoding`, laid out plane by plane and row by
// row. Write it from the point of view of the side to move: "my pieces" and
// "their pieces", not "red" and "yellow", so one network plays both sides.

const ROWS = 6;
const COLS = 7;

interface Drop {
  /** cells[row * COLS + col]: 0 empty, 1 the first seat, -1 the second. */
  cells: number[];
  toMove: 1 | -1;
}

const encoding: RulesKernel<Drop, number>["encoding"] = { planes: 2, height: ROWS, width: COLS };

const encode: RulesKernel<Drop, number>["encode"] = (s) => {
  const planeSize = ROWS * COLS;
  const out = new Float32Array(encoding.planes * planeSize);
  s.cells.forEach((cell, index) => {
    if (cell === s.toMove) out[index] = 1; // plane 0: mine
    else if (cell === -s.toMove) out[planeSize + index] = 1; // plane 1: theirs
  });
  return out;
};

const start: Drop = { cells: new Array<number>(ROWS * COLS).fill(0), toMove: 1 };
world.log(`encode() gives ${encode(start).length} values for ${encoding.planes} planes of ${ROWS} x ${COLS}`);
```

#### Gotchas

**The length is checked.** A wrong length is reported by the trainer's check (`encode-length`) and rejects
`ai.mcts` with a model at the table; a NaN or an infinity is `encode-not-finite`; anything other than a
`Float32Array` is `encode-not-float32`.

**Change it and you change the model's language.** Bump [`version`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelversion).

#### See also

- [`RulesKernel.encoding`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencoding) — the shape this has to fill.
- [`manifest.aiModels[].inputs`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodels) — a model's input is this, batched.

### ruleskernel.encoding

```ts
encoding: { planes: number; height: number; width: number };
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The shape of `encode`'s output: `planes` stacked `height` x `width` grids, row by row.

The shape of [`encode`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencode)'s output: `planes` stacked grids of `height × width`. Required.

#### How, why and when to use it

It tells three things how to read your encoding: the search (to lay out a batch for the model), the training
tools (to size a network's board), and the game records (written into every header). A model searched with this
kernel must declare exactly one `float32` input of shape `[-1, planes, height, width]`.

A game that is not a grid still has one: use `height: 1` and put your features along `width`.

#### Gotchas

**All three must be positive whole numbers**, or the trainer's check reports `kernel-shape`.

#### See also

- [`RulesKernel.encode`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelencode) — what fills it.
- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — how a model is matched to a kernel.

### ruleskernel.policySize

```ts
policySize: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Length of the policy vector: one slot per move the game can ever offer, pass included.

How many policy slots the game has: one for every move it can ever offer, pass included.

#### How, why and when to use it

A model's policy output has this many values per position, and [`MctsResult.policy`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultpolicy) has
this many entries. For a board game it is usually the number of squares, plus one for a pass; for a game with
richer moves (from-square and to-square) it is the number of distinct moves you can number.

#### Gotchas

**Fixed per kernel version.** The model's output size is baked in at training; change this and bump
[`version`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelversion).

#### See also

- [`RulesKernel.moveIndex`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex) — numbering the moves into these slots.

### ruleskernel.moveIndex

```ts
moveIndex(m: M): number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

A move's slot in the policy vector, from 0 to `policySize - 1`. Different legal moves get different slots.

A move's slot in the policy vector: an integer from 0 to `policySize - 1`.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `m` | `M` | A move. |

#### Returns

`number` — the slot. Different legal moves of one position get different slots.

#### How, why and when to use it

It is how a model's policy output is matched to your moves, and how the search's visit counts are reported back
in [`MctsResult.policy`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultpolicy). A fixed numbering of everything the game can do is the usual
choice: square `row × width + col`, then a final slot for the pass.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.moveIndex.ts

// Scene script: moveIndex() for a board game whose moves are a square or a pass.
//
// One slot per move the game can EVER offer, from 0 to policySize - 1, and two
// different legal moves never share a slot. The model's policy output is read
// through this map, so changing it is a new kernel version: an old model's
// slots would mean different moves.

const SIZE = 8;

type Placement = { row: number; col: number } | "pass";

const policySize: RulesKernel<unknown, Placement>["policySize"] = SIZE * SIZE + 1;

const moveIndex: RulesKernel<unknown, Placement>["moveIndex"] = (move) =>
  move === "pass" ? SIZE * SIZE : move.row * SIZE + move.col;

world.log(`corner -> ${moveIndex({ row: 7, col: 7 })}, pass -> ${moveIndex("pass")} of ${policySize}`);
```

#### Gotchas

**Out of range rejects the search.** `ai.mcts` rejects a `moveIndex` outside `0 … policySize - 1`; the trainer's
check reports it as `move-index-out-of-range`.

#### See also

- [`RulesKernel.moveAt`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveat) — the inverse, which must agree with this.

### ruleskernel.moveAt

```ts
moveAt(i: number): M;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The move a policy slot stands for: `moveAt(moveIndex(m))` equals `m`.

The move a policy slot stands for — the inverse of [`moveIndex`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex).

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `i` | `number` | A slot from 0 to `policySize - 1`. |

#### Returns

`M` — the move, such that `moveAt(moveIndex(m))` equals `m` for every legal move.

#### How, why and when to use it

Training uses it to turn a recorded slot back into a move; tools that inspect a model's policy use it to label
the slots. Write the two together and test the round trip.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.moveAt.ts

// Scene script: moveAt(), the inverse of moveIndex(), and the round trip that
// proves the two agree.
//
// moveAt(moveIndex(m)) must equal m for every legal move. The search reads a
// policy slot back into a move with it, so a slot that comes back as a
// different move makes the bot play something it never searched.

const SIZE = 8;
const PASS_SLOT = SIZE * SIZE;

type Placement = { row: number; col: number } | "pass";

const moveIndex: RulesKernel<unknown, Placement>["moveIndex"] = (move) =>
  move === "pass" ? PASS_SLOT : move.row * SIZE + move.col;

const moveAt: RulesKernel<unknown, Placement>["moveAt"] = (index) =>
  index === PASS_SLOT ? "pass" : { row: Math.floor(index / SIZE), col: index % SIZE };

// The round trip, over every slot. Worth keeping in your own script while you
// develop a kernel; the trainer runs the same check before any self-play.
for (let slot = 0; slot <= PASS_SLOT; slot += 1) {
  if (moveIndex(moveAt(slot)) !== slot) {
    world.log(`slot ${slot} does not round-trip`);
  }
}
```

#### Gotchas

**"Equals" means the same JSON.** The trainer's check compares `moveAt(moveIndex(m))` with `m` as data and reports
`move-index-round-trip` when they differ — including when a property is in a different order or an extra field
appears.

#### See also

- [`RulesKernel.policySize`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelpolicysize) — the range of `i`.

### ruleskernel.heuristic

```ts
heuristic?(s: S): number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Optional hand-written estimate in -1..1 for the player to move. `ai.mcts` falls back to it when no model can run.

An optional hand-written estimate of the position, from -1 (the side to move is losing) to 1 (it is winning).
`ai.mcts` uses it whenever there is no model: always, when you leave out `modelId`; and as the fallback when the
host cannot run the model.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `s` | `S` | The position. |

#### Returns

`number` in -1..1, for the seat [`currentPlayer`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelcurrentplayer) names.

#### How, why and when to use it

It is the most valuable thing you can write for a bot. The search turns even a crude estimate — material, mobility,
corners held — into strong play by checking it many moves deep. On Reversi the model-less search beat a greedy
player 90% of the time at 64 simulations and drew level with a depth-4 minimax at 256. A trained model is only
worth shipping if it beats this search **at the same number of simulations**.

#### Example

```ts
// content/scripting-api/examples/ruleskernel.heuristic.ts

// Scene script: heuristic() for a capture game - a quick guess at who is
// winning, used by ai.mcts whenever there is no model to ask.
//
// Return a number in -1..1 for the SIDE TO MOVE: positive when it is ahead.
// It is called a great many times per move, so keep it to a few arithmetic
// operations - no search inside it. A search with only this and no model is
// already strong in practice; a model has to beat it to be worth shipping.

interface Material {
  /** Pieces left, per seat. */
  pieces: Record<string, number>;
  seats: [string, string];
  toMove: 0 | 1;
}

const heuristic: NonNullable<RulesKernel<Material, number>["heuristic"]> = (s) => {
  const me = s.seats[s.toMove];
  const them = s.seats[s.toMove === 0 ? 1 : 0];
  const mine = s.pieces[me] ?? 0;
  const theirs = s.pieces[them] ?? 0;
  if (mine + theirs === 0) {
    return 0;
  }
  // (mine - theirs) / total is already in -1..1.
  return (mine - theirs) / (mine + theirs);
};

world.log(`ahead by ${heuristic({ pieces: { red: 9, blue: 6 }, seats: ["red", "blue"], toMove: 0 }).toFixed(2)}`);
```

#### Gotchas

**Without one, a host that cannot run your model plays random legal moves.** Every kernel used by an AI bot should
have a heuristic for exactly that case.

**Cheap, and from the mover's side.** It is called for many positions per move, so no search inside it. A value
for the wrong side makes the bot play to lose. Outside -1..1 (or not finite) is clamped at the table and reported
by the trainer's check as `heuristic-range`.

#### See also

- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — how the search uses it.
- [Training a bot model](https://diceytable.com/docs/mods/guides/training-bots) — measuring a model against it.

## MctsSpec

_Surface A — table script · interface · 10 members_

What `ai.mcts` searches: a kernel, a position, how hard to think and, optionally, a model.

What [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) searches: a [`kernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspeckernel), a [`state`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecstate), how many
[`simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) to run and, optionally, a [`modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecmodelid) — plus four tuning
knobs that are best left at their defaults until you have measured a reason to change them, and
[`playerCount`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecplayercount) for a game with more than two seats.

### How, why and when to use it

Build one per turn: the kernel is fixed, the state is the position your script read off the table, and the
simulations come from the bot's difficulty. Pass `signal: ctx.signal` so the search stops when the turn does.

### See also

- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — the search.
- [`MctsResult`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresult) — what comes back.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`kernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspeckernel) | The rules to search with. | `RulesKernel<S, M>` |
| [`modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecmodelid) | The id of a model declared in the manifest's `aiModels`. It supplies the policy and the value. | `string \| null` |
| [`state`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecstate) | The position to search from. It must not be over. | `S` |
| [`simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) | Playouts to run. More is stronger and slower: this is the usual difficulty dial. | `number` |
| [`cPuct`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspeccpuct) | How readily the search tries moves the model rates low. Default 1.5. | `number` |
| [`temperature`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspectemperature) | 0 (the default) plays the most-visited move; above 0 picks at random, weighted by visits. | `number` |
| [`dirichlet`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecdirichlet) | Extra randomness at the first move of the search, for training games. Leave unset at the table. | `{ alpha: number; epsilon: number }` |
| [`batchSize`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecbatchsize) | Positions sent to the model per call. Default 8, at most 256. | `number` |
| [`playerCount`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecplayercount) | How many seats play this game. The search scores a position for the player to move and shares the opposite of that score out among the other seats, so a three- or four-player game needs the real number here. | `number` |
| [`signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsignal) | Stops the search early. Pass the `signal` your bot's `onTurn` was given. | `BotAbortSignal` |

### mctsspec.kernel

```ts
kernel: RulesKernel<S, M>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The rules to search with.

The rules to search with — your [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel).

#### How, why and when to use it

Pass the same `kernel` object every time; it is plain functions and holds no state of its own. With a model, the
kernel's `encoding` and `policySize` must match the model's declared input and outputs, or the search rejects.

#### Gotchas

**Nothing checks the kernel at the table.** A kernel that mutates its input or mislabels a move plays badly rather
than failing. Run it through the trainer's check (`diceytable-bot-trainer`, any command that takes `--kernel`)
while you develop it.

#### See also

- [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) — writing one.

### mctsspec.modelId

```ts
modelId?: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The id of a model declared in the manifest's `aiModels`. It supplies the policy and the value.

Leave it out (or pass `null`) to search WITHOUT a model: every position is then scored with
`kernel.heuristic` and a one-move look-ahead, and no model is ever called, so this works on
any device and needs no `aiModels` entry. A kernel with no `heuristic` gets a random legal
move. This is how a scripted bot gets a strong search out of a good heuristic.

The `id` of a model in your manifest's [`aiModels`](https://diceytable.com/docs/mods/manifest/reference#aimodels), which supplies the
search's move preferences (policy) and position values. Optional.

#### Returns

`string | null | undefined`. Leave it out, or pass `null`, to search **without** a model.

#### How, why and when to use it

Without a model every position is scored with `kernel.heuristic` and a one-move look-ahead, nothing is ever sent
to the host's inference workers, and no `aiModels` entry or hardware tier is needed. This is how a **scripted**
bot gets a strong search out of a good heuristic.

With a model, the search sends positions to it in batches. If the host cannot run it, the search carries on as if
you had left `modelId` out.

#### Gotchas

**A kernel with no `heuristic` and no usable model plays a random legal move.** Give every kernel a heuristic.

**Only your own game's models.** An id that is not in your manifest rejects the search with `ai-model-unknown`.

#### See also

- [`RulesKernel.heuristic`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelheuristic) — what the search uses instead.
- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — the input and output shape a model must have.

### mctsspec.state

```ts
state: S;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The position to search from. It must not be over.

The position to search from, as your kernel's `S`. It must not be over.

#### How, why and when to use it

Build it from the table at the start of the bot's turn — read the pieces, translate them into your state shape —
with the bot as [`currentPlayer`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelcurrentplayer). The search never looks at the table, so the state
is everything it knows.

#### Gotchas

**A finished position rejects the search.** Check `kernel.terminal(state)` first and skip the turn if it is not
`null`.

**Everything hidden from the bot should be hidden from the state.** The script on the host can read every card;
whether the bot plays fair is decided by what you put here. See
[Game bots: hidden information](https://diceytable.com/docs/mods/concepts/bots#hidden-information).

#### See also

- [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) — the state's shape is yours.

### mctsspec.simulations

```ts
simulations: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Playouts to run. More is stronger and slower: this is the usual difficulty dial.

How many playouts to run. More is stronger and slower — the usual difficulty dial. At least 1.

#### How, why and when to use it

Declare it per difficulty as a model's `simsPerMove` and pass `ctx.difficulty?.simsPerMove` (for a scripted bot,
map its difficulty ids to numbers in your script): for example 16, 64 and 256 for easy, normal and hard (the
ladder the editor suggests for a model). Measured on a desktop for Reversi, a model-less search costs about 1 ms
a move at 64 simulations and 5 ms at 256; a phone or an old laptop can be five to ten times slower. With a model,
each batch of positions is a model call, so the cost follows the model's size and the host's tier.

#### Gotchas

**It is a ceiling, not a promise.** When the turn's signal fires the search stops early and returns the best it
has; [`MctsResult.visits`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvisits) says how many finished.

#### See also

- [`BotTurnContext.difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty) — the declared `simsPerMove` to pass.
- [`ai.tier`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitier) — scaling it by the host's device.

### mctsspec.cPuct

```ts
cPuct?: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

How readily the search tries moves the model rates low. Default 1.5.

How readily the search tries moves the model (or heuristic) rates low. Default 1.5.

#### Returns

`number | undefined`. Higher explores more; lower trusts the prior more.

#### How, why and when to use it

Leave it alone unless you are tuning a model and measuring the result. The same value is used when models are
trained, and searching at the table with a different one plays a slightly different game than the model learned.

#### Gotchas

**It is not a difficulty setting.** Change `simulations` or `temperature` to make a bot weaker or stronger.

#### See also

- [`MctsSpec.simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) — the dial to turn instead.

### mctsspec.temperature

```ts
temperature?: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

0 (the default) plays the most-visited move; above 0 picks at random, weighted by visits.

How the final move is picked from the search's visit counts. 0, the default, plays the most-visited move; above 0
picks at random, weighted by visits — the higher, the more varied.

#### Returns

`number | undefined`.

#### How, why and when to use it

For a bot that should not play the same game twice, or an "easy" level that makes believable mistakes: a small
temperature (0.3–1) on the opening moves, 0 afterwards, is the common pattern.

#### Gotchas

**Randomness is not the same as weakness.** A high temperature makes occasional terrible moves rather than
consistently mediocre ones. Fewer simulations is the smoother way to weaken a bot.

#### See also

- [`MctsResult.policy`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultpolicy) — the distribution it samples from.

### mctsspec.dirichlet

```ts
dirichlet?: { alpha: number; epsilon: number };
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Extra randomness at the first move of the search, for training games. Leave unset at the table.

Extra randomness mixed into the search's first move, for training games: `alpha` shapes the noise, `epsilon` is
how much of it is mixed in.

#### Returns

`{ alpha: number; epsilon: number } | undefined`.

#### How, why and when to use it

Self-play training uses it so a model sees varied positions. At the table, leave it unset.

#### Gotchas

**It makes the bot worse on purpose.** It pushes the search towards moves it would not otherwise consider.

#### See also

- [`MctsSpec.temperature`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspectemperature) — the table's way to vary a bot's play.

### mctsspec.batchSize

```ts
batchSize?: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Positions sent to the model per call. Default 8, at most 256.

How many positions the search sends to the model per call. Default 8, at most 256.

#### Returns

`number | undefined`.

#### How, why and when to use it

On the processor a model's cost per position barely changes with the batch, so the default is fine. On a
graphics card each call has a fixed cost of several milliseconds and batching is the whole win — for a model
declared `minTier: "gpu"`, try 32 or 64. Without a model it has no effect.

#### Gotchas

**A bigger batch is a less precise search** for the same number of simulations, because the positions in one batch
are chosen before any of their results are known.

#### See also

- [Game bots: hardware tiers](https://diceytable.com/docs/mods/concepts/bots#hardware-tiers) — the measured per-call costs.

### mctsspec.playerCount

```ts
playerCount?: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

How many seats play this game. The search scores a position for the player to move and
shares the opposite of that score out among the other seats, so a three- or four-player game
needs the real number here.

Left out, it is the length of `state.seats` when your state keeps the seats in an array of
that name (as a state built by `kernel.initial(seats)` usually does), else the number of
seated players and bots at the table, else 2.

How many seats play the game. The search scores a position for the player to move and shares the opposite of that
score out among the other seats, each taking an equal part, so a game for three or more needs the real number.

#### Returns

`number | undefined`.

#### How, why and when to use it

Leave it out for a two-player game. Left out, `ai.mcts` fills it in: the length of `state.seats` when your state
keeps its seats in an array of that name (a state built by `kernel.initial(seats)` usually does), else the number
of players and bots holding a seat at the table, else 2.

#### Gotchas

**Three or more is an approximation.** Sharing a loss equally assumes every other seat loses alike when the mover
gains; the search is built and tested for two-player games where one side's gain is the other's loss.

#### See also

- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts).

### mctsspec.signal

```ts
signal?: BotAbortSignal;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Stops the search early. Pass the `signal` your bot's `onTurn` was given.

Stops the search early. Pass the `signal` your bot's `onTurn` was given.

#### Returns

`BotAbortSignal | undefined`.

#### How, why and when to use it

When it fires, the search resolves with the best move it has found once at least one playout has finished, and
rejects with an error named `"AbortError"` if none has. Its model calls are cancelled too.

#### Gotchas

**Resolving is not permission to move.** A search that stopped because the turn ran out still hands you a move;
check `ctx.signal.aborted` before playing it.

#### See also

- [`BotTurnContext.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) — where it comes from.

## MctsResult

_Surface A — table script · interface · 4 members_

What `ai.mcts` resolves with.

What [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) resolves with: the [`move`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultmove) to play, plus what the search learned
about the position — its visit distribution, its value estimate and how many playouts it finished.

### How, why and when to use it

Play `move`. The other three are for logging, for commentary ("the bot thinks it is winning"), and for
judging whether your simulations are enough.

### See also

- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — the search.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`move`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultmove) | The chosen move. | `M` |
| [`policy`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultpolicy) | How the search spread its visits over the policy vector (sums to 1). | `Float32Array` |
| [`value`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvalue) | Estimated result in -1..1 for the player to move. | `number` |
| [`visits`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvisits) | Playouts that finished. | `number` |

### mctsresult.move

```ts
move: M;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The chosen move.

The chosen move — one of `kernel.legalMoves(state)`.

#### How, why and when to use it

Turn it into script calls: spawn or move the piece, flip the card. That translation is your script's job; the
search knows nothing about the table.

#### Gotchas

**Check the turn is still yours first** (`ctx.signal.aborted`), especially after a long search.

#### See also

- [`RulesKernel.apply`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelapply) — what the move means to your rules.

### mctsresult.policy

```ts
policy: Float32Array;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

How the search spread its visits over the policy vector (sums to 1).

How the search spread its visits over the policy slots: a `Float32Array` of `policySize` values that sum to 1.
Slot `i` is the move `kernel.moveAt(i)`.

#### How, why and when to use it

For a "top three moves" display or a hint, read the largest entries. It is also what training learns from.

#### Gotchas

**A forced move reads 1 in its slot and 0 elsewhere** — no search was run for it.

#### See also

- [`RulesKernel.moveIndex`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelmoveindex) — the slot numbering.

### mctsresult.value

```ts
value: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Estimated result in -1..1 for the player to move.

The search's estimate of the position, from -1 to 1, for the side to move (the bot): positive when it believes it
is winning.

#### How, why and when to use it

For commentary, or a bot that offers a draw or resigns when it is clearly lost.

#### Gotchas

**0 for a forced move.** With one legal move nothing was evaluated, so this says nothing about the position.

#### See also

- [`MctsResult.visits`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvisits) — how much evidence it rests on.

### mctsresult.visits

```ts
visits: number;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Playouts that finished.

How many playouts finished.

#### How, why and when to use it

Compare it with the `simulations` you asked for. Consistently fewer means the search is being cut off by the
turn's time budget on this host — lower the simulations, or raise the budget with `params.turnBudgetMs`.

#### Gotchas

**0 means a forced move**, answered without searching.

#### See also

- [`MctsSpec.simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) — what you asked for.

## BotTurnContext

_Surface A — table script · interface · 7 members_

What a bot's `onTurn` is told about the turn it has to play.

What [`onTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonturn) is told about the turn it has to play: which bot (`peerId`, `botId`),
where it sits (`seat`), how hard it plays (`difficultyId`, and [`difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty) with the
dials your manifest declares for it), the model an AI bot plays with at that difficulty
([`modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextmodelid)), and a
[`signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) that fires when it should stop.

### How, why and when to use it

Everything a handler needs to tell one bot from another is here, so one handler can play every instance of a bot
at the table — two "Medium" bots in two seats are two calls with two contexts.

### See also

- [`BotRegistration.onTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonturn) — where you receive it.
- [`PlayerInfo`](https://diceytable.com/docs/scripting-api/types#playerinfo) — the same bot as `world.getPlayers()` describes it.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`peerId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextpeerid) | The bot's peer id (it starts with `bot:`): the id `onTurnStarted` and `world.getPlayers()` report for it. | `string` |
| [`seat`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextseat) | The seat the bot holds, or `null` while it has none. | `string \| null` |
| [`botId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextbotid) | The bot's id from the manifest's `bots` list. | `string` |
| [`difficultyId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficultyid) | The difficulty the bot is playing at, or `null` when it declares none. | `string \| null` |
| [`modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextmodelid) | The model an AI bot plays with at its current difficulty, ready to pass to `ai.mcts` or `ai.evaluate`: the difficulty's own `modelId` in the manifest's `bots` list when it names one, else the bot's `modelId`. `null` for a scripted bot. | `string \| null` |
| [`difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty) | The difficulty the bot is playing at, with the dials the manifest declares for it, or `null` when it plays at none. Read the dials here instead of copying them into your script. | `BotDifficultyInfo \| null` |
| [`signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) | Fires when the bot should stop thinking: its time for this turn ran out, or it was removed from the table. Pass it to `ai.mcts` and `ai.evaluate`, and check `signal.aborted` before making the move. | `BotAbortSignal` |

### botturncontext.peerId

```ts
readonly peerId: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The bot's peer id (it starts with `bot:`): the id `onTurnStarted` and `world.getPlayers()` report for it.

The bot's peer id. Every bot's starts with `bot:`, which no person's can, and it is the id the turn order,
`globalEvents.onTurnStarted` and `world.getPlayers()` use for it.

#### How, why and when to use it

Pass it to [`bots.skipTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsskipturn), and key any per-bot state on it. It is minted when the bot is added
and kept in the table's state, so it survives a save, a reload and a host migration.

#### Gotchas

**It identifies one seated instance, not the kind of bot.** Two copies of the same manifest bot have different
peer ids and the same [`botId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextbotid).

#### See also

- [`BotTurnContext.botId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextbotid) — which manifest bot it is.

### botturncontext.seat

```ts
readonly seat: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The seat the bot holds, or `null` while it has none.

The seat the bot holds, as the table's seat assignments say right now.

#### Returns

`string | null`. `null` while the bot has no seat — a person took its chair, for example. A bot without a seat is
normally paused and does not get a turn, but write the check anyway.

#### How, why and when to use it

To find "my" pieces: a hand zone, a seat-tagged pile, the side of the board this seat plays from. Use
[`world.getSeatZones(ctx.seat)`](https://diceytable.com/docs/scripting-api/world#worldgetseatzones) for the seat's areas.

#### Gotchas

**A seat can change between turns.** The host can move a bot to another seat. Read it each turn rather than
remembering it from the first.

#### See also

- [`world.getSeatZones`](https://diceytable.com/docs/scripting-api/world#worldgetseatzones) — where the seat's zones are.

### botturncontext.botId

```ts
readonly botId: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The bot's id from the manifest's `bots` list.

The bot's `id` from the manifest's [`bots`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabots) list — which of your game's
declared bots this is.

#### How, why and when to use it

When one script plays several declared bots and they differ by more than strength. Usually you do not need it:
each declared bot has its own handler, so the handler already knows.

#### Gotchas

**It is not the handler id.** `botId` is `bots[].id`; the id you registered is `bots[].handler`. They are often
written the same, but nothing requires it.

#### See also

- [`BotTurnContext.difficultyId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficultyid) — how hard it should play.

### botturncontext.difficultyId

```ts
readonly difficultyId: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The difficulty the bot is playing at, or `null` when it declares none.

The difficulty the bot is playing at: the `id` of one of its declared
[`difficulties`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabotsdifficulties), as picked in the lobby or the Bots panel.

#### Returns

`string | null`. `null` for a bot that declares no difficulties. A bot that declares some always has one — the
requested one, else its `defaultDifficulty`, else its first.

#### How, why and when to use it

This is your difficulty dial, and only your script turns it. Map each id to whatever "harder" means for your bot:
more `simulations` for [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts), a deeper hand-written search, less randomness. For an AI bot the
numbers its model declares for this id arrive with it as [`ctx.difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty).

#### Gotchas

**Nothing is applied for you.** The `simsPerMove`, `temperature` and `params` a model's `difficulties` declare are
handed to your script in `ctx.difficulty`, matched by this same id; `ai.mcts` uses only what you pass it. The one
value the table itself uses is `params.turnBudgetMs`. Keep your bot's difficulty ids and its model's difficulty ids
the same, or `ctx.difficulty` has no dials.

**The host can change it mid-game**, from Settings ▸ Bots. Read it every turn.

#### See also

- [`manifest.bots[].difficulties`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabotsdifficulties) — declaring them.
- [`MctsSpec.simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) — the usual thing to scale.

### botturncontext.modelId

```ts
readonly modelId: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The model an AI bot plays with at its current difficulty, ready to pass to `ai.mcts` or
`ai.evaluate`: the difficulty's own `modelId` in the manifest's `bots` list when it names
one, else the bot's `modelId`. `null` for a scripted bot.

The model an AI bot plays with **at its current difficulty**, ready to pass to [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) or
[`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate): the difficulty's own
[`modelId`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabots) in the manifest's `bots` list when it names one,
else the bot's `modelId` (its default model).

#### Returns

`string | null`. `null` for a scripted bot (it names no model).

#### How, why and when to use it

Pass it straight on — `ai.mcts({ kernel, modelId: ctx.modelId, … })` — instead of repeating the model id in your
script. One handler can then play an AI bot and a scripted one: for the scripted bot `ctx.modelId` is `null`, and
`ai.mcts` searches with your kernel's heuristic and never calls a model.

#### Gotchas

**Pass `ctx.modelId`, not a model id of your own per difficulty.** A bot whose Easy, Medium and Hard are different
networks declares that on the difficulties (`difficulties[].modelId`), and `ctx.modelId` already names the right one.
That declaration is also what the host loads ahead of time, what its device is checked against and what the bot is
ranked on; a script that picks a different model itself evaluates one the platform does not know it uses.

**It is the declaration, not a promise the host can run it.** On a host that cannot run the model `ai.mcts` carries
on with `kernel.heuristic`, and `ai.evaluate` rejects `ai-unavailable`.

#### See also

- [`BotTurnContext.difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty) — that model's dials for this bot's difficulty.
- [`MctsSpec.modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecmodelid) — where it goes.

### botturncontext.difficulty

```ts
readonly difficulty: BotDifficultyInfo | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The difficulty the bot is playing at, with the dials the manifest declares for it, or
`null` when it plays at none. Read the dials here instead of copying them into your script.

The difficulty the bot is playing at, with the dials your manifest declares for it — or `null` when it plays at
none.

#### Returns

[`BotDifficultyInfo`](https://diceytable.com/docs/scripting-api/bots-and-ai#botdifficultyinfo)` | null`. For an AI bot the dials (`simsPerMove`, `temperature`,
`params`) come from its model's [`difficulties`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodelsdifficulties)
entry with the same id. When the model does not declare that id, and for every scripted bot, the dials are `null`
and `params` is empty: only `id` and `label` are filled in, from the bot's own `difficulties`.

#### How, why and when to use it

Read your difficulty dials here instead of copying them into your script, so the manifest is the one place they
are written: `simulations: ctx.difficulty?.simsPerMove ?? 64`. Put anything else a difficulty should change — a
search depth, how often a poker bot bluffs — in the model difficulty's `params` and read
`ctx.difficulty?.params["depth"]`.

#### Gotchas

**Nothing is applied for you.** The dials are handed to your script; `ai.mcts` uses only what you pass it. The one
value the table itself reads is `params.turnBudgetMs`, the bot's time for a turn.

**Only this bot's turn sees it, and it is frozen.** `world.getPlayers()` does not carry it.

**The host can change the difficulty mid-game.** Read it every turn.

#### See also

- [`BotTurnContext.difficultyId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficultyid) — the same difficulty's id.
- [`MctsSpec.simulations`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspecsimulations) and [`MctsSpec.temperature`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspectemperature) — where the dials go.

### botturncontext.signal

```ts
readonly signal: BotAbortSignal;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Fires when the bot should stop thinking: its time for this turn ran out, or it was removed
from the table. Pass it to `ai.mcts` and `ai.evaluate`, and check `signal.aborted`
before making the move.

Fires when the bot should stop thinking: its time for this turn ran out, it was removed or paused, or the turn
moved on without it.

#### How, why and when to use it

Pass it to [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) and [`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate), and check `ctx.signal.aborted` once more just
before making the move. When it is `true`, the table has already skipped this turn.

#### Gotchas

**It does not stop your code — but the table refuses a late move.** Your handler keeps running until it returns;
the signal only tells it to. Once the turn is gone, every table change that handler still makes throws (or
rejects) with an `AbortError` (`bot-turn-over`) and nothing changes — see [`BotAbortSignal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignal).
Checking `aborted` before you move is still how a handler stops cleanly instead of failing.

#### See also

- [`BotAbortSignal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignal) — its three members.
- [`BotRegistration.onTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonturn) — the time budget.

## BotRegistration

_Surface A — table script · interface · 4 members_

The handler a script registers for one of its game's bots.

What you pass to [`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister): the handler `id` a manifest bot names, the `onTurn` that
plays its turns, and two optional notifications for when one of those bots sits down or leaves.

### How, why and when to use it

Write it inline, as an object literal inside the call — the publish scanner reads `id` straight out of that
literal. The three functions are kept inside your script's sandbox; the host only ever learns the `id`.

### See also

- [`BotRegistration.onTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonturn) — the one that matters.
- [`BotTurnContext`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontext) — what `onTurn` is handed.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`id`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationid) | The `handler` id of a bot declared in the manifest's `bots` list. | `string` |
| [`onTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonturn) | Play one turn for the bot. Called on the host when a turn starts for a seat this bot holds. Make the move with ordinary script calls and return (or resolve the promise) when the move is made: the table then passes the turn to the next seat. If it throws, or is still running when the turn's time runs out, the table skips the turn with a notice rather than leave the game stuck. | `(ctx: BotTurnContext) => Promise<void> \| void` |
| [`onAdded`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonadded) | Called when one of these bots takes a seat. | `(player: PlayerInfo) => void` |
| [`onRemoved`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonremoved) | Called when one of these bots leaves the table. | `(player: PlayerInfo) => void` |

### botregistration.id

```ts
id: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The `handler` id of a bot declared in the manifest's `bots` list.

The `handler` id of a bot declared in the manifest's [`bots`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabots) list:
lowercase letters, digits and hyphens, starting with a letter, at most 40 characters.

#### How, why and when to use it

Match it exactly to `bots[].handler` in your manifest. The editor's Bots tab lists the ids your scripts register
and lets you pick one, so the two cannot drift apart by a typo.

#### Gotchas

**A plain string literal, written in the call.** `bots.register({ id: "greedy", … })` is what publish can read.
A constant, a template with `${…}`, a concatenation or a shorthand `{ id }` all work when the script runs — and
all leave the bot looking unregistered to the scanner, which refuses it as `bot-handler-missing`.

**An id that is not a valid handler id throws** at `register`, before anything is registered.

#### See also

- [`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister) — duplicates, and when "no handler" is decided.
- [Manifest ▸ Bots](https://diceytable.com/docs/editor/manifest/bots) — the Handler and Registered fields.

### botregistration.onTurn

```ts
onTurn: (ctx: BotTurnContext) => Promise<void> | void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Play one turn for the bot. Called on the host when a turn starts for a seat this bot holds.
Make the move with ordinary script calls and return (or resolve the promise) when the move
is made: the table then passes the turn to the next seat. If it throws, or is still running
when the turn's time runs out, the table skips the turn with a notice rather than leave the
game stuck.

Plays one turn for a bot. Called on the host when the turn passes to a seat held by a bot with this handler, with
a [`BotTurnContext`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontext) describing the turn. Make the move with ordinary script calls; **when the
function returns, or its promise resolves, the table ends the turn.**

#### Returns

`Promise<void> | void`. What it resolves with is ignored. A rejection, a throw, or still running when the turn's
time is up are all a failed turn.

#### How, why and when to use it

Everything about how a bot plays lives here: read the table, decide (by hand, with [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) or with
[`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate)), act. Make it `async` — most of what it calls returns a promise.

#### Gotchas

**A failed turn is skipped, three in a row pause the bot.** The table posts "*Bot*'s turn was skipped" with the
reason (an error, a time-out, no handler), and after the third consecutive failure pauses the bot with "its
script is not working". The host resumes it from Settings ▸ Bots. A turn that completes, or a
[`bots.skipTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsskipturn), resets the count.

**The time budget is 30 seconds by default.** A game can set another per difficulty with
`params.turnBudgetMs` on the bot's model's difficulty entry (between 0.25 s and 5 minutes); see
[`aiModels[].difficulties[].params`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodelsdifficulties).

**When the bot is the only one who can play**, finishing its turn does not hand the turn back to itself forever:
the turn stays open on the bot, rather than loop.

**Stopped scripts mean no turns.** If the host stops the scripts (or restarts them on a promoted host) mid-turn,
the turn is abandoned and dispatched again once a handler is registered — even when another mod's scripts on the
table keep running meanwhile. If no script registers the handler within about a second of the scripts restarting
(or of them stopping for good), the turn is skipped with "no handler".

**Finish your move before you return.** Once `onTurn` has settled — or its turn timed out or was aborted — the
turn is over, and any table change its code still makes (a fire-and-forget promise, a timer, a handler that
ignored [`ctx.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal)) is refused with an `AbortError`; see
[`BotAbortSignal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignal).

#### See also

- [`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister) — a complete example.
- [`BotTurnContext.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) — how you learn the time is up.

### botregistration.onAdded

```ts
onAdded?: (player: PlayerInfo) => void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Called when one of these bots takes a seat.

Called when a bot with this handler takes a seat at the table, with that bot's
[`PlayerInfo`](https://diceytable.com/docs/scripting-api/types#playerinfo). Optional.

#### Returns

`void`. What it returns is ignored.

#### How, why and when to use it

For per-bot set-up: give a bot its own pieces, greet it, start keeping a record keyed on its `peerId`. It also
runs for bots that were already seated when your script registered — so a script that loads after the table
started still hears about every bot.

#### Gotchas

**`globalEvents.onPlayerJoined` fires too.** That event is for every arrival, person or bot; this one only for
bots this handler plays. Do your set-up in one of the two, not both.

#### See also

- [`BotRegistration.onRemoved`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonremoved) — the other end.
- [`bots.list`](https://diceytable.com/docs/scripting-api/bots-and-ai#botslist) — every bot seated right now.

### botregistration.onRemoved

```ts
onRemoved?: (player: PlayerInfo) => void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Called when one of these bots leaves the table.

Called when a bot with this handler leaves the table — the host removed it, or swapped it for a scripted
fallback — with the bot's last [`PlayerInfo`](https://diceytable.com/docs/scripting-api/types#playerinfo). Optional.

#### Returns

`void`. What it returns is ignored.

#### How, why and when to use it

To tidy up after a bot: clear its per-bot state, hand its pieces back to the box, post a line. If the bot was in
the middle of a turn, that turn's `signal` has already fired.

#### Gotchas

**A paused bot has not been removed.** Pausing keeps the bot at the table (and in the turn order, skipped); only
removal calls this.

#### See also

- [`BotRegistration.onAdded`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistrationonadded) — the other end.
- [`globalEvents.onPlayerLeft`](https://diceytable.com/docs/scripting-api/events#globaleventsonplayerleft) — fires for every departure, bots included.

## Bots

_Surface A — table script · interface · 3 members_

The game's bots: register the code that plays them, and see which are seated.

Reached from a script as the injected global `bots`.

```ts
declare const bots: Bots;
```

The game's bots singleton.

The `bots` global: the three calls through which a scene script plays its game's bots. The manifest declares
which bots a game offers and gives each a `handler` id; the script registers code under that id with
[`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister), and from then on the table calls it whenever one of those bots has a turn.

### How, why and when to use it

You need it whenever your game's manifest has a [`bots`](https://diceytable.com/docs/mods/manifest/reference#bots) list — and only
then. A bot with no registered handler cannot move: about a second after the scripts start, a turn that falls
to it is skipped as "no handler", and publishing is refused (`bot-handler-missing`) for any declared bot whose
handler no script registers.

Everything a bot does on its turn is an ordinary script call. There is no separate "bot API" for moving pieces:
`world.spawnObject`, `handle.setPosition`, `handle.flip`, `handle.destroy` and the rest are the same calls a
rules script already makes, and they reach the other players the same way.

### Gotchas

**Host only, like every table script.** `bots` exists in every script's sandbox, but scripts only ever run on
the host. A bot's thinking costs the host's CPU, not the players'.

**One bot thinks at a time.** The table dispatches one bot turn and waits for it; two bots never run their
`onTurn` at once, even if the turn order would let them.

**A table with no running script pauses its bots.** A table whose scripts register no handler for a seated bot
leaves it with nothing to play it, so the host pauses it and says so rather than leave a seat that looks alive.
Which scripts run is per variant (a variant attaches its own), so a variant whose scripts do not register a bot's
handler cannot seat that bot in the first place: the lobby and the Bots panel refuse it
(`bot-variant-no-handler`), and switching a lobby to that variant removes the bots already seated.

**An offline solo table has no turn order.** Bots there only move if your own script drives the turns.

### See also

- [`BotRegistration`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistration) — what you pass to `register`.
- [`BotTurnContext`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontext) — what `onTurn` is told.
- [Game bots](https://diceytable.com/docs/mods/concepts/bots) — the concept page.
- [`manifest.bots`](https://diceytable.com/docs/mods/manifest/reference#bots) — declaring a bot.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`register(bot: BotRegistration)`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister) | Register the handler for a bot declared in the manifest's `bots` list. One registration per id. | `void` |
| [`list()`](https://diceytable.com/docs/scripting-api/bots-and-ai#botslist) | The bots seated right now: `world.getPlayers()` narrowed to the entries with `isBot`. | `PlayerInfo[]` |
| [`skipTurn(peerId: string, reason: string)`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsskipturn) | Skip a bot's turn and tell the table why, for a bot that cannot move this turn. The table does the same by itself when a bot's `onTurn` throws or runs out of time. | `Promise<void>` |

### bots.register

```ts
register(bot: BotRegistration): void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Register the handler for a bot declared in the manifest's `bots` list. One registration per
id.

Host-authoritative like every other write: scripts run on the host only, so a bot's moves
are made there and reach the other players as ordinary table changes.

Registers the code that plays one of your game's bots. `id` is the `handler` a bot in the manifest's
[`bots`](https://diceytable.com/docs/mods/manifest/reference#bots) list names; from then on, whenever a seat held by that bot gets
the turn, the table calls your `onTurn` on the host.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `bot` | [`BotRegistration`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistration) | `id` (the handler id), `onTurn`, and optionally `onAdded` / `onRemoved`. |

#### How, why and when to use it

Call it once per handler, as the script loads. Handlers are one-to-one with declared bots — two bots naming the
same handler fail the manifest check — so strengths are usually **difficulties** of one bot, read from
`ctx.difficultyId` in one handler. Two genuinely different bots (a scripted one and an AI one) get two handlers,
which may share helpers.

**The table ends the bot's turn when `onTurn` returns** (or its promise resolves). That is the bot's equivalent
of a person pressing End Turn; there is no script call that ends a turn. Make the move with ordinary script
calls, then return. If `onTurn` throws, or is still running when the turn's time runs out (30 seconds unless the
game sets another), the table skips the turn with a notice. Three failed turns in a row pause the bot with
"its script is not working" until the host resumes it from Settings ▸ Bots.

To pass on purpose — the bot has no legal move — call [`bots.skipTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsskipturn) and return. A skip is
not a failure and does not count towards the three.

#### Example

```ts
// content/scripting-api/examples/bots.register.ts

// Scene script: a complete scripted opponent for noughts and crosses.
//
// The board is nine snap points labelled "c0" to "c8", and a placed mark is a
// token tagged "mark-x" or "mark-o". The manifest declares the bot, and its
// "handler" is the id registered below:
//   { "id": "easy", "label": "Easy", "kind": "scripted", "handler": "random-cell" }
//
// The table ends the bot's turn when onTurn returns - the same thing a person
// does by pressing End Turn. There is no call that ends a turn.

const CELL = /^c[0-8]$/;

async function freeCells(): Promise<SnapPointInfo[]> {
  const cells = (await world.getSnapPoints()).filter((point) => CELL.test(point.label));
  const taken = new Set<string>();
  for (const tag of ["mark-x", "mark-o"]) {
    for (const piece of await world.getAllObjects({ tag })) {
      const cell = await world.getSnapPointAt(piece.position);
      if (cell) {
        taken.add(cell.id);
      }
    }
  }
  return cells.filter((cell) => !taken.has(cell.id));
}

// Crosses always move first, so the mark to play is whichever has fewer pieces.
async function markToPlay(): Promise<"x" | "o"> {
  const crosses = (await world.getAllObjects({ tag: "mark-x" })).length;
  const noughts = (await world.getAllObjects({ tag: "mark-o" })).length;
  return crosses === noughts ? "x" : "o";
}

bots.register({
  // Write the id as a plain string, right here: publish reads it out of the
  // script to check that every bot in the manifest has a handler.
  id: "random-cell",

  async onTurn(ctx) {
    const free = await freeCells();
    if (free.length === 0) {
      // Nothing to play is not an error. Say why, and the turn moves on.
      await bots.skipTurn(ctx.peerId, "the board is full");
      return;
    }
    const cell = free[Math.floor(Math.random() * free.length)];
    const mark = await markToPlay();

    // The signal fires when this turn's time runs out or the bot is removed.
    // A move made after that would land on somebody else's turn.
    if (ctx.signal.aborted) {
      return;
    }
    await world.spawnObject({
      kind: "token",
      name: `mark-${mark}`,
      position: [cell.position[0], cell.position[1] + 0.1, cell.position[2]],
      tags: [`mark-${mark}`]
    });
  },

  onAdded(player) {
    world.broadcast(`${player.displayName ?? "A bot"} sits down to play.`);
  }
});
```

#### Gotchas

**Write `id` as a plain string literal inside the call.** Publishing reads handler ids straight out of each
scene script's *compiled* body, and it only recognises `bots.register({ id: "random-cell", … })` written
inline. `bots.register(spec)`, `id: HANDLER`, `` id: `bot-${n}` `` or reaching `bots` under another name all
work at the table, but the scanner cannot see them and refuses the bot as `bot-handler-missing`.

**One registration per id.** Registering the same id twice in one script throws. Across scripts, the first
registration wins and the second is reported in the script console — a handler id belongs to exactly one script
on the table.

**"No handler" is decided about a second after the scripts start.** A script that registers after an `await` at
load is fine if it does so within that second; later than that, a turn already reported as "no handler" is
skipped rather than played late.

**Per-turn action limits do not apply to bots.** A table's "actions per turn" limit is for people; a bot's
script may make as many calls as its move needs.

**Check `ctx.signal.aborted` before you move.** When the time runs out the table has already skipped the turn.
A move made afterwards is refused — the call throws (or rejects) with an `AbortError` (`bot-turn-over`) and the
table is unchanged — and anything `onTurn` reports after that point is ignored. The same holds for work left
running after `onTurn` has returned: finish the move before you return.

**"No handler" waits for a restart too.** When the game's scripts restart mid-turn (a reload, a resume) the
second is counted from the restart, so the restarted script's registration plays the turn even while another
mod's scripts on the table kept running.

**`Math.random` is fine here** — a handler is not a rules kernel. The kernel you search with must not use it.

#### See also

- [`BotRegistration`](https://diceytable.com/docs/scripting-api/bots-and-ai#botregistration) and [`BotTurnContext`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontext) — the two shapes involved.
- [`bots.skipTurn`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsskipturn) — passing on purpose.
- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — searching for the move instead of picking it by hand.
- [`manifest.bots[].handler`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemabots) — the id this registers.
- [What gets rejected](https://diceytable.com/docs/mods/scanner/what-gets-rejected#bot-handler-missing) — `bot-handler-missing`.

### bots.list

```ts
list(): PlayerInfo[];
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The bots seated right now: `world.getPlayers()` narrowed to the entries with `isBot`.

The bots seated at the table right now: [`world.getPlayers()`](https://diceytable.com/docs/scripting-api/world#worldgetplayers)
narrowed to the entries whose `isBot` is `true`. Synchronous — answered from the roster the host pushes into
the sandbox.

#### Returns

[`PlayerInfo[]`](https://diceytable.com/docs/scripting-api/types#playerinfo) — one entry per bot, `[]` when there are none. Each
carries `botId` (which of the manifest's bots it is) and `difficultyId` (the difficulty it plays at, or `null`).
`displayName` is the bot's label; `isHost` is always `false`.

#### How, why and when to use it

Use it for anything that is about the opponents as a group: a "you are playing against…" line at the start, a
rule that only applies when a bot is at the table, a scoreboard that marks bot seats. For "is it a bot's turn
right now", compare `world.getTurn().activePeerId` with these entries' `peerId`s — or simply check that the id
starts with `bot:`, which every bot's peer id does and no person's can.

#### Example

```ts
// content/scripting-api/examples/bots.list.ts

// Scene script: say who the opponents are when the game starts, and keep a
// "bots at this table" label up to date as bots come and go.
//
// bots.list() is world.getPlayers() narrowed to the bots, so the same entries
// carry botId (which manifest bot it is) and difficultyId (how hard it plays).

function describeBots(): string {
  const seated = bots.list();
  if (seated.length === 0) {
    return "No bots at this table";
  }
  return seated
    .map((bot) => `${bot.displayName ?? bot.botId}${bot.difficultyId ? ` (${bot.difficultyId})` : ""}`)
    .join(", ");
}

function showBots(): void {
  world.setTextLabel({ id: "bot-roster", text: describeBots(), position: [0, 0, -3] });
}

showBots();

// A bot joining or leaving is reported like any other player.
globalEvents.onPlayerJoined.add((player) => {
  if (player.isBot) {
    showBots();
  }
});
globalEvents.onPlayerLeft.add((player) => {
  if (player.isBot) {
    showBots();
  }
});
```

#### Gotchas

**It is a copy, refreshed when the roster changes.** Keep calling it rather than holding the array: a bot can be
added, removed or swapped by the host at any time from Settings ▸ Bots.

**A paused bot is still listed.** `PlayerInfo` has no status field, so a bot the host paused (its device cannot
run the model, or its script kept failing) looks the same here as one that is playing. Its turns are skipped by
the table either way.

**`seat` can be `null`.** A player can take a bot's seat at the table; the bot stays, paused and seatless.

**Bots arrive through the ordinary player events.** `globalEvents.onPlayerJoined` and `onPlayerLeft` fire for
bots too, with `isBot: true`, so you do not need to poll this.

#### See also

- [`PlayerInfo.isBot`](https://diceytable.com/docs/scripting-api/types#playerinfoisbot) — the flag this filters on.
- [`world.getPlayers`](https://diceytable.com/docs/scripting-api/world#worldgetplayers) — everyone, people and bots.
- [`api.listBots`](https://diceytable.com/docs/scripting-api/api#apilistbots) — the mod surface's version, which does carry `status`.

### bots.skipTurn

```ts
skipTurn(peerId: string, reason: string): Promise<void>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

Skip a bot's turn and tell the table why, for a bot that cannot move this turn. The table
does the same by itself when a bot's `onTurn` throws or runs out of time.

Host-authoritative, and refused on a peer.

Gives up a bot's turn on purpose and tells the table why. The table posts "*Bot* skipped its turn: *reason*"
for everyone and passes the turn to the next seat.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `peerId` | `string` | The bot's peer id — `ctx.peerId` inside `onTurn`. A peer id that is not a seated bot is ignored. |
| `reason` | `string` | Shown to every player. Plain text, cut to 200 characters. May be empty. |

#### Returns

`Promise<void>`, resolved once the host has taken the skip. Return from `onTurn` straight after it.

#### How, why and when to use it

When a bot genuinely has nothing to do: no legal move, no cards left, the board is full. A skip is the honest
answer and costs the bot nothing — it is **not** a failure, so it does not count towards the three failed turns
that pause a bot, and it clears any failures already counted.

Do not use it to end a turn after a move. Returning from `onTurn` does that.

#### Example

```ts
// content/scripting-api/examples/bots.skipTurn.ts

// Scene script: a bot that plays one card a turn onto the discard pile, and
// passes - with a reason every player sees - when it has none to play.
//
// Cards dealt to a seat carry the tag "hand-<seat>". The discard pile is a
// snap point labelled "discard".

bots.register({
  id: "card-player",

  async onTurn(ctx) {
    if (!ctx.seat) {
      // A bot can be seated-less for a moment (a player took its chair).
      await bots.skipTurn(ctx.peerId, "it has no seat");
      return;
    }
    const hand = await world.getAllObjects({ kind: "card", tag: `hand-${ctx.seat}` });
    const discard = (await world.getSnapPoints()).find((point) => point.label === "discard");
    if (hand.length === 0 || !discard) {
      // A pass on purpose. This is not counted against the bot: three FAILED
      // turns in a row pause it, and a skip is not a failure.
      await bots.skipTurn(ctx.peerId, hand.length === 0 ? "no cards left to play" : "there is no discard pile");
      return;
    }

    const card = hand[0];
    card.setPosition(discard.position);
    card.flip();
    // Returning ends the turn. Nothing else to call.
  }
});
```

#### Gotchas

**A game whose rules have a pass move should play the pass, not skip.** If passing is part of your game (and a
rules kernel requires it to be a move), make the pass on the table the way a person would; `skipTurn` is for
"this bot cannot take part this turn".

**Skipping only moves the turn on when it is the bot's turn.** Called for a bot that is not the active player,
it posts the notice and changes nothing else.

**Refused on a peer.** Only the host plays bots; on any other peer the promise rejects.

#### See also

- [`bots.register`](https://diceytable.com/docs/scripting-api/bots-and-ai#botsregister) — where `onTurn` and its `ctx.peerId` come from.
- [`BotTurnContext.peerId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextpeerid) — the id to pass.

## AiTensor

_Surface A — table script · type_

One model input: 32-bit floats, or 64-bit integers for a model that takes indices.

```ts
declare type AiTensor = Float32Array | BigInt64Array;
```

One model input as [`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate) takes it: a flat `Float32Array` (for a `float32` input) or
`BigInt64Array` (for an `int64` one) holding **every position of the batch end to end**.

### How, why and when to use it

There is no shape attached — the shape is the one your manifest declares for that input, and the host checks the
length against it. For an input declared `[-1, 3, 8, 8]`, one position is 192 values and a batch of five is a
single array of 960. The first dimension `-1` is the batch; everything after it is one position, row by row.

### Gotchas

**Use the right element type.** A `Float32Array` handed to an `int64` input (or the reverse) is refused as
`ai-shape-mismatch`; nothing is converted for you.

**A length that is not a whole number of positions is refused**, and so is a batch whose inputs disagree on how
many positions they hold.

### See also

- [`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate) — the call that takes these.
- [`manifest.aiModels[].inputs`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodels) — the declared names, shapes and types.

## AiEvaluateOptions

_Surface A — table script · interface · 1 members_

Options for one `ai.evaluate` call.

The optional third argument to [`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate): a `signal` to give up early. (A call takes no
difficulty — a bot's difficulty reaches its script as [`ctx.difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty).)

### How, why and when to use it

Inside a bot's turn, pass `{ signal: ctx.signal }`. Then a turn that runs out of time stops waiting for its model
call at once instead of holding the host's attention for a result nobody will use.

### See also

- [`AiEvaluateOptions.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluateoptionssignal).
- [`BotTurnContext.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) — the signal to pass.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluateoptionssignal) | Gives up on the call when it fires. Pass the `signal` your bot's `onTurn` was given. | `BotAbortSignal` |

### aievaluateoptions.signal

```ts
signal?: BotAbortSignal;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Gives up on the call when it fires. Pass the `signal` your bot's `onTurn` was given.

Gives up on the call when the signal fires: the promise rejects with `ai-budget-exceeded` (as an error named
`"AbortError"`) and the host stops waiting for the worker.

#### Returns

`BotAbortSignal | undefined`. Leave it out and the call is bounded only by its own 2-second budget.

#### How, why and when to use it

Pass the `signal` your bot's `onTurn` was given. A turn that times out, or a bot the host removes mid-thought,
then cancels its model calls instead of finishing them for nothing.

#### Gotchas

**An already-fired signal rejects immediately**, without sending anything to the host.

**The rejection uses the time-out code.** From your script's side a cancelled call and a call that ran out of
time are the same thing — the answer did not come in time — so both are `ai-budget-exceeded`.

#### See also

- [`BotTurnContext.signal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextsignal) — where the signal comes from.
- [`BotAbortSignal`](https://diceytable.com/docs/scripting-api/bots-and-ai#botabortsignal) — what it is.

## Ai

_Surface A — table script · interface · 3 members_

Model-backed bots: run a model the manifest declares, and search with it.

Reached from a script as the injected global `ai`.

```ts
declare const ai: Ai;
```

The model-inference singleton.

The `ai` global: search a position with your own rules ([`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts)), run one of your game's declared
models directly ([`ai.evaluate`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate)), and read what the host's device can do ([`ai.tier`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitier)).

### How, why and when to use it

Most bots want `ai.mcts`. It is a complete game-tree search (Monte-Carlo tree search, AlphaZero style) over a
[`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) you write, and it works with or without a model: without one it scores positions
with your kernel's `heuristic`, with one it asks the model for move preferences and a position value. Either
way you get one move back and play it with ordinary script calls.

`ai.evaluate` is the lower-level call underneath: hand a declared model some encoded positions and get its raw
outputs back. Use it for a bot that needs no search (one model call picks the move), or for anything that is
not a move at all — a hint, a position evaluation, a commentary line.

### Gotchas

**A model is data, never code.** The models `ai` can run are `.onnx` files your manifest declares in
[`aiModels`](https://diceytable.com/docs/mods/manifest/reference#aimodels), checked by the publish scanner against a fixed list of
plain operators. A script cannot load anything else, cannot name a model its game does not declare, and never
holds the model's bytes.

**Host only.** Scripts run on the host, so the host's device does the work, and results never leave it.

**Plan for "no model".** The host may be a device that cannot run your model — a phone, an old laptop — and then
`ai.evaluate` rejects with `"ai-unavailable"` and `ai.mcts` quietly falls back to your `heuristic`. A bot that
still plays well without its model is a bot that works at every table.

### See also

- [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) — what `ai.mcts` searches.
- [Game bots](https://diceytable.com/docs/mods/concepts/bots) — hardware tiers, model sizes and what to expect from a model.
- [`api.ai.evaluate`](https://diceytable.com/docs/scripting-api/api#apiaievaluate) — the mod surface's version of `ai.evaluate`.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`evaluate(modelId: string, inputs: Record<string, AiTensor>, opts?: AiEvaluateOptions)`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluate) | Run a declared model on a batch of inputs and resolve with its outputs, keyed by the tensor names the manifest declares. Each input holds every position of the batch end to end; a batch is at most 256 positions. | `Promise<Record<string, Float32Array>>` |
| [`tier()`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitier) | The hardware tier of this host, as probed. `"none"` also when this device has not been measured yet (it is measured when an AI bot is seated at a table it hosts). | `HardwareTier` |
| [`mcts<S, M>(spec: MctsSpec<S, M>)`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) | Search a position with a rules kernel and, optionally, a policy/value model, and resolve with the move to play (AlphaZero-style tree search). The model is called for you, in batches. | `Promise<MctsResult<M>>` |

### ai.evaluate

```ts
evaluate(modelId: string, inputs: Record<string, AiTensor>, opts?: AiEvaluateOptions): Promise<Record<string, Float32Array>>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

Run a declared model on a batch of inputs and resolve with its outputs, keyed by the tensor
names the manifest declares. Each input holds every position of the batch end to end; a
batch is at most 256 positions.

Pure: the same inputs give the same outputs on this host. Results stay on the host — they
are never sent to other players and never saved.

Runs on the host only, and rejects with an `Error` whose `message` is one of:
`"not-host"` (called on a peer), `"ai-unavailable"` (this host cannot run the model),
`"ai-budget-exceeded"` (the call ran past its 2-second budget, its `signal` fired, or this
script already had four calls in flight), `"ai-model-unknown"` (no such id in the manifest's
`aiModels`), `"ai-shape-mismatch"` (an input's name, type or size is not what the model
declares, or the call is over 256 positions or 4 MiB of input).
Always handle the rejection: a bot that cannot think must still take or skip its turn.

Runs one of your game's declared models on a batch of positions and resolves with its outputs. You name the
model by its `aiModels[].id`; the host checks your inputs against that declaration, runs the model on its own
device in a background worker, and hands back typed arrays.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `modelId` | `string` | The `id` of an entry in your manifest's [`aiModels`](https://diceytable.com/docs/mods/manifest/reference#aimodels). |
| `inputs` | `Record<string, AiTensor>` | One entry per input tensor the model declares, by its declared name — no more, no fewer. Each value is **one flat array holding every position of the batch, end to end**: for an input declared `[-1, 2, 3, 3]`, four positions are a `Float32Array` of `4 × 2 × 3 × 3` values. `Float32Array` for a `float32` input, `BigInt64Array` for an `int64` one. |
| `opts` | [`AiEvaluateOptions`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluateoptions) | Optional. `signal` to give up early — pass your turn's `ctx.signal`. |

#### Returns

`Promise<Record<string, Float32Array>>` — one entry per output the model declares, by declared name, each a flat
`Float32Array` in the same position order as the inputs. For an output declared `[-1, 9]` and a batch of four,
`out.policy` has 36 values: positions one to four, nine each.

Rejects with an `Error` whose `message` is exactly one of:

| `message` | Means |
|---|---|
| `not-host` | Called on a peer that is not the host. |
| `ai-unavailable` | This host cannot run the model: its device is below the model's `minTier`, the model file could not be loaded and verified, or the inference worker failed. An ordinary answer on a weak device — not a bug. |
| `ai-model-unknown` | `modelId` is not one of your manifest's `aiModels`. |
| `ai-shape-mismatch` | An input is missing or extra, has the wrong element type, or its length is not a whole number of positions; the inputs disagree on how many positions there are; the call is over 256 positions or 4 MiB of input; the batch's declared outputs would be over 8 MiB; or the batch is over the model's own batch limit (see Gotchas). |
| `ai-budget-exceeded` | The call ran past its 2-second budget, its `signal` fired, this script already had four evaluations in flight, or the model has been limited to smaller batches after overrunning its budget and this batch is over that limit (see Gotchas). |

#### How, why and when to use it

Reach for it when one model call is the whole decision — a policy network that ranks moves, a value network
that scores a handful of candidate positions you built yourself. Batch: evaluating twenty candidate positions
in one call is far cheaper than twenty calls, because most of a call's cost on a fast device is the round trip,
not the arithmetic.

If what you want is a *searched* move, use [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) instead: it calls this for you, in batches, and
falls back to your heuristic when the model is unavailable.

#### Example

```ts
// content/scripting-api/examples/ai.evaluate.ts

// Scene script: an AI bot that asks a model for its move directly, one call
// per turn, and falls back to a random legal move when the model cannot run.
//
// The manifest declares the model (aiModels) with these tensors:
//   inputs:  [{ "name": "board",  "shape": [-1, 2, 3, 3], "dtype": "float32" }]
//   outputs: [{ "name": "policy", "shape": [-1, 9], "dtype": "float32" },
//             { "name": "value",  "shape": [-1, 1], "dtype": "float32" }]
// and an AI bot { "kind": "ai", "modelId": "ttt-net", "handler": "net-player" }.

type Cell = "x" | "o" | null;

// Read the board off the table: tokens tagged "mark-x" / "mark-o" on the
// snap points "c0" to "c8".
async function readBoard(): Promise<Cell[]> {
  const cells: Cell[] = [null, null, null, null, null, null, null, null, null];
  for (const mark of ["x", "o"] as const) {
    for (const piece of await world.getAllObjects({ tag: `mark-${mark}` })) {
      const point = await world.getSnapPointAt(piece.position);
      const index = point ? Number(point.label.slice(1)) : -1;
      if (index >= 0 && index < 9) {
        cells[index] = mark;
      }
    }
  }
  return cells;
}

// Two 3x3 planes, seen by the side to move: plane 0 = my marks, plane 1 = theirs.
function encode(cells: Cell[], me: "x" | "o"): Float32Array {
  const planes = new Float32Array(2 * 9);
  cells.forEach((cell, index) => {
    if (cell === me) planes[index] = 1;
    else if (cell !== null) planes[9 + index] = 1;
  });
  return planes;
}

bots.register({
  id: "net-player",

  async onTurn(ctx) {
    const cells = await readBoard();
    const me = cells.filter((cell) => cell === "x").length === cells.filter((cell) => cell === "o").length ? "x" : "o";
    const legal = cells.flatMap((cell, index) => (cell === null ? [index] : []));
    if (legal.length === 0) {
      await bots.skipTurn(ctx.peerId, "the board is full");
      return;
    }

    let choice = legal[Math.floor(Math.random() * legal.length)];
    try {
      // One position, so each input holds exactly one position's values.
      const out = await ai.evaluate("ttt-net", { board: encode(cells, me) }, { signal: ctx.signal });
      // policy holds 9 raw scores; pick the best one that is a legal cell.
      choice = legal.reduce((best, index) => (out.policy[index] > out.policy[best] ? index : best), legal[0]);
    } catch (error) {
      // "ai-unavailable" is an ordinary answer: this host cannot run the model.
      // The bot still has to move, so it keeps the random choice.
      world.log(`net-player: model not used (${(error as Error).message})`);
    }

    if (ctx.signal.aborted) {
      return;
    }
    const point = (await world.getSnapPoints()).find((entry) => entry.label === `c${choice}`);
    if (point) {
      await world.spawnObject({ kind: "token", name: `mark-${me}`, position: point.position, tags: [`mark-${me}`] });
    }
  }
});
```

#### Gotchas

**Always catch.** A bot whose turn throws has its turn skipped; three in a row and it is paused. On a device that
cannot run the model every call rejects with `ai-unavailable`, so the catch is where your bot still plays.

**At most four calls in flight per script.** A fifth concurrent call is refused at once with
`ai-budget-exceeded` rather than queued. Await your calls, or batch them into one.

**A cancelled call still counts until the host stops working on it.** When your `signal` fires, or a call runs
out of time, the promise rejects straight away — but the call keeps its in-flight slot until the host's work for
it has actually stopped, at most the call's budget plus one second later. Cancelling four slow calls and
immediately starting four more therefore gets `ai-budget-exceeded` for the new ones. Cancelling is a way to stop
waiting, not a way around the cap.

**Outputs are capped at 8 MiB a call.** Every output the model declares, for every position in the batch, at four
bytes a value, must total 8 MiB or less, or the call is refused `ai-shape-mismatch`. A typical policy head is
nowhere near it — a 4,673-value chess policy fits a full 256-position batch — but a very large one sets the batch
for you: an 11,259-move policy fits at most 186 positions.

**A big model may take fewer than 256 positions.** When your model is checked, the platform works out how much
memory it uses at each batch size and gives it a batch limit: the largest batch, up to 256, that stays within the
platform's memory caps. Ordinary board-game networks keep the full 256 (15×15 and 19×19 boards with the trainer's
`tiny`, `small` and `medium` presets all do); a large tower on a large board gets less — 88 for a 19×19 board with
12 blocks of 192 filters, 61 for 13 blocks of 256. A call over the limit is refused `ai-shape-mismatch`, every
time — waiting or pressing Resume does not change it. A script cannot read the limit, so if a large batch is
refused with `ai-shape-mismatch` and your inputs are otherwise right, halve the batch and try again.

**A model that keeps running out of time takes smaller batches.** If a model overruns its budget three calls in a
row, the host limits it to half the batch of the last overrunning call, and any bigger call is refused
`ai-budget-exceeded` at once instead of being started. The limit normally lasts for the rest of the game on that
host. Treat
an `ai-budget-exceeded` on a large batch as a signal to send smaller ones.

**Results stay on the host and are never saved.** Nothing a model returns is replicated; only the moves your
script makes with it reach the other players.

**The same inputs give the same outputs on this host — not across hosts.** Different devices evaluate with
different arithmetic, so after a host migration the same position may produce slightly different numbers. Never
build game logic that needs two hosts to agree on a model's output.

**A call has no difficulty.** One evaluation is one evaluation; how hard a bot plays is your script's choice, made
from [`ctx.difficulty`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty) — the dials (`simsPerMove`, `temperature`, `params`) the
manifest declares for the difficulty it is playing at.

**The model is loaded ahead of the first turn.** The host loads the models its active AI bots need as soon as
they are seated, so a bot's first call is not usually paying for the download. A model no seated AI bot uses is
not loaded in advance.

#### See also

- [`AiTensor`](https://diceytable.com/docs/scripting-api/bots-and-ai#aitensor) and [`AiEvaluateOptions`](https://diceytable.com/docs/scripting-api/bots-and-ai#aievaluateoptions) — what goes in.
- [`ai.mcts`](https://diceytable.com/docs/scripting-api/bots-and-ai#aimcts) — a search that calls this for you.
- [`manifest.aiModels[].inputs`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodels) — the declaration your inputs are checked against.
- [`api.ai.evaluate`](https://diceytable.com/docs/scripting-api/api#apiaievaluate) — the same call on the mod surface.

### ai.tier

```ts
tier(): HardwareTier;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The hardware tier of this host, as probed. `"none"` also when this device has not been
measured yet (it is measured when an AI bot is seated at a table it hosts).

The host's hardware tier — what this device can do for a model-backed bot, as the hardware check measured it.
Synchronous: the host pushes the value into the sandbox.

#### Returns

[`HardwareTier`](https://diceytable.com/docs/scripting-api/bots-and-ai#hardwaretier): `"none"`, `"cpu-basic"`, `"cpu-fast"` or `"gpu"`, weakest first.

#### How, why and when to use it

To budget. A search that is comfortable on a desktop can freeze a phone for seconds, and the host is whatever
device the room ended up on. Scale your `simulations` (or your search depth, for a hand-written bot) by tier.

It is not a permission check. Whether a table may *start* with an AI bot, and whether a promoted host pauses
one, is decided by the platform from the model's declared `minTier` before your script is ever asked; and
`ai.evaluate` itself answers `ai-unavailable` on a device that cannot run the model. You never need to compare
tiers to stay safe.

#### Example

```ts
// content/scripting-api/examples/ai.tier.ts

// Scene script: think harder on a host that can afford it.
//
// ai.tier() is the HOST's device class, as the hardware probe measured it -
// the scripts run on the host, so that is the machine doing the thinking.
// It never changes what the bot is allowed to do; it only helps it budget.

function playoutsForThisHost(base: number): number {
  switch (ai.tier()) {
    case "gpu":
      return base * 4;
    case "cpu-fast":
      return base * 2;
    case "cpu-basic":
      return base;
    default:
      // "none": this device cannot run a model, or it has not been measured yet
      // (the host is measured when an AI bot is seated). A search with no model
      // still works, so keep it cheap rather than giving up.
      return Math.max(16, Math.floor(base / 2));
  }
}

world.log(`bots on this table search with ${playoutsForThisHost(64)} playouts a move (${ai.tier()})`);
```

#### Gotchas

**`"none"` also means "not measured".** The check downloads the inference runtime, so a device is only measured
when an AI bot is seated at a table it hosts, or when its player tests it in Settings ▸ Graphics; the result is
kept for a week. A host that has never been measured reads `"none"` even on a fast machine.

**It is self-reported.** A tier is what the host's own browser measured. It is advisory: it decides what the
lobby allows and whom the server prefers as the next host, and it grants nothing.

**It describes the host, which can change.** After a host migration the scripts restart on the new host and
read its tier.

#### See also

- [`HardwareTier`](https://diceytable.com/docs/scripting-api/bots-and-ai#hardwaretier) — what each tier means.
- [Game bots: hardware tiers](https://diceytable.com/docs/mods/concepts/bots#hardware-tiers) — how tiers gate hosting, and the measured costs.
- [`manifest.aiModels[].minTier`](https://diceytable.com/docs/mods/manifest/reference#modmanifestschemaaimodels) — what a model asks for.

### ai.mcts

```ts
mcts<S, M>(spec: MctsSpec<S, M>): Promise<MctsResult<M>>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

Search a position with a rules kernel and, optionally, a policy/value model, and resolve with
the move to play (AlphaZero-style tree search). The model is called for you, in batches.

If the model cannot run on this host the search carries on with `kernel.heuristic`; with no
heuristic it picks a legal move at random. Leave `modelId` out to search with the heuristic
from the start: no model is called, so a scripted bot can use it on any device. Built for
two-player games in which one side's gain is the other's loss; for more seats set
`playerCount` (it is filled in from the position or the table when you leave it out).
Results differ between devices, so never rely on two hosts choosing the same move.

Searches a position with your [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) and resolves with the move to play. It is a
Monte-Carlo tree search in the AlphaZero style: it plays `simulations` imagined continuations from `state`,
guided either by a declared model (its move preferences and position value) or, with no model, by your kernel's
`heuristic`, and returns the move it found best.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `spec` | [`MctsSpec<S, M>`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspec) | `kernel`, `state`, `simulations`, and optionally `modelId`, `cPuct`, `temperature`, `dirichlet`, `batchSize`, `playerCount`, `signal`. |

#### Returns

`Promise<MctsResult<M>>` — [`move`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultmove) to play, the visit distribution as
[`policy`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultpolicy), the search's [`value`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvalue) estimate for the side to move, and
how many playouts finished ([`visits`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresultvisits)).

#### How, why and when to use it

**Without a model** — leave `modelId` out or pass `null` — the search scores each position with
`kernel.heuristic` after a one-move look-ahead, and never calls a model. It needs no `aiModels` entry and runs on
any host, so it is the right brain for a **scripted** bot. Measured on Reversi, this alone beats a greedy player
90% of the time at 64 simulations and plays level with a depth-4 minimax at 256. Write a good heuristic before you
think about training anything.

**With a model** — `modelId` names one of your `aiModels` — the model supplies a prior over moves and a value for
each position, and the search asks it in batches of `batchSize`. If the host cannot run the model (its call
answers `ai-unavailable`), the search carries on with `kernel.heuristic`, or picks a random legal move if the
kernel has none. Your bot keeps playing either way.

`simulations` is the difficulty dial. More is stronger and slower; on a phone, keep it modest. Declare it per
difficulty as a model's `simsPerMove` and pass [`ctx.difficulty?.simsPerMove`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextdifficulty); pass
[`ctx.modelId`](https://diceytable.com/docs/scripting-api/bots-and-ai#botturncontextmodelid) as `modelId`, and one handler serves an AI bot and a scripted one alike.

For a game with more than two seats, set `playerCount` — or keep the seats in `state.seats`, and it is read from
there.

The model must fit the kernel: exactly **one `float32` input**, batched along a first dimension of `-1`, holding
`planes × height × width` values per position (your `kernel.encoding`); and two batched outputs — a
**policy** with `policySize` values per position and a **value** with one. Name them `policy` and `value`; if
they are named otherwise, the search matches them by size and refuses when that is ambiguous.

#### Example

```ts
// content/scripting-api/examples/ai.mcts.ts

// Scene script: two bots for a take-away game, sharing one search.
//
// The game: a pile of stones (tokens tagged "stone"); on your turn take 1, 2
// or 3; whoever takes the last stone wins. The manifest declares a scripted
// bot { "handler": "stones-search" } and an AI bot { "handler": "stones-net",
// "modelId": "stones-net" }. Both call ai.mcts - the scripted one WITHOUT a
// model, so its search scores positions with kernel.heuristic instead.

interface Pile {
  stones: number;
  seats: string[];
  toMove: number;
}
type Take = 1 | 2 | 3;

const MAX_STONES = 15;

const kernel: RulesKernel<Pile, Take> = {
  id: "take-away",
  version: 1,
  initial: (seats) => ({ stones: MAX_STONES, seats: [...seats], toMove: 0 }),
  currentPlayer: (s) => s.seats[s.toMove],
  legalMoves: (s) => ([1, 2, 3] as Take[]).filter((take) => take <= s.stones),
  apply: (s, take) => ({ stones: s.stones - take, seats: s.seats, toMove: (s.toMove + 1) % s.seats.length }),
  terminal: (s) => {
    if (s.stones > 0) {
      return null;
    }
    // The player now "to move" faces an empty pile: the other one took the last stone.
    const value: Record<string, number> = {};
    s.seats.forEach((seat, index) => {
      value[seat] = index === s.toMove ? -1 : 1;
    });
    return { value };
  },
  // One plane, 1 x 16: a one-hot of the pile size.
  encoding: { planes: 1, height: 1, width: MAX_STONES + 1 },
  encode: (s) => {
    const planes = new Float32Array(MAX_STONES + 1);
    planes[Math.min(s.stones, MAX_STONES)] = 1;
    return planes;
  },
  policySize: 3,
  moveIndex: (take) => take - 1,
  moveAt: (index) => (index + 1) as Take,
  // A pile that is a multiple of 4 loses for the player to move.
  heuristic: (s) => (s.stones % 4 === 0 ? -0.8 : 0.8)
};

async function takeStones(ctx: BotTurnContext): Promise<void> {
  const stones = await world.getAllObjects({ tag: "stone" });
  // The search only needs to know who is who: the bot is to move, against "the other side".
  const state: Pile = { stones: stones.length, seats: [ctx.peerId, "opponent"], toMove: 0 };
  if (kernel.terminal(state) !== null) {
    await bots.skipTurn(ctx.peerId, "the pile is empty");
    return;
  }

  // The bot's model (null for the scripted one) and the simulations its
  // difficulty declares in the manifest - nothing restated here.
  const result = await ai.mcts({
    kernel,
    modelId: ctx.modelId,
    state,
    simulations: ctx.difficulty?.simsPerMove ?? 200,
    signal: ctx.signal
  });
  if (ctx.signal.aborted) {
    return;
  }
  for (const stone of stones.slice(0, result.move)) {
    stone.destroy();
  }
  world.log(`took ${result.move} after ${result.visits} playouts (value ${result.value.toFixed(2)})`);
}

// Scripted: no model (ctx.modelId is null), so no aiModels entry, no hardware
// requirement, any host.
bots.register({ id: "stones-search", onTurn: takeStones });

// AI: the same search, with the policy and value from the declared model. On a
// host that cannot run the model, ai.mcts carries on with kernel.heuristic.
bots.register({ id: "stones-net", onTurn: takeStones });
```

#### Gotchas

**It rejects instead of guessing.** A `state` that is already over (`terminal` is not `null`), a non-terminal
state with no legal moves, a `moveIndex` outside `0 … policySize - 1`, an `encode` of the wrong length, or a
model whose tensors do not fit the kernel (an error whose `code` is `"ai-shape-mismatch"`) all reject. Catch
it, and fall back to a simple move.

**One legal move is answered at once.** With exactly one move available — a forced pass, say — the search
returns it immediately with `visits: 0` and `value: 0`, and calls nothing.

**Abort keeps what it has.** When `signal` fires, the search resolves with the best move so far once at least
one playout has finished; before that, it rejects with an error named `"AbortError"`. Either way, check
`ctx.signal.aborted` before you play the move: an aborted turn has already been skipped.

**Built for two players.** A model's (or heuristic's) value is "for the side to move", and the search charges
every other seat the full opposite of it. With two players that is exactly right. With three or more it assumes
every opponent is out to get the mover — a cautious, "paranoid" search that plays weaker than a per-seat
evaluation would. Terminal results are exact for any number of seats.

**It runs inside your script's sandbox, on the host's main thread.** The model calls go to background workers;
the tree, your kernel and the heuristic do not. A model-less search of thousands of simulations is a visible
stall on a slow host. A model-less search hands the thread back about every 12 ms so that your turn's abort can
arrive, but the work in between still runs on the host's main thread.

**Different hosts choose differently.** The search is seeded per call, and a model's numbers differ slightly
between devices. Never rely on two hosts — before and after a migration — picking the same move.

**`temperature` above 0 is randomness on purpose.** At 0 (the default) the bot plays the most-visited move.
Raise it for a more varied, weaker bot; leave `dirichlet` unset at the table — it is for training games.

#### See also

- [`MctsSpec`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsspec) and [`MctsResult`](https://diceytable.com/docs/scripting-api/bots-and-ai#mctsresult) — the shapes in and out.
- [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) — what you have to write first.
- [`RulesKernel.heuristic`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelheuristic) — the brain of a model-less search.
- [Training a bot model](https://diceytable.com/docs/mods/guides/training-bots) — measuring whether a model beats the heuristic.
