Dicey Table

Table Scripting (Surface A)

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.

On this page14 sections

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) and which models it ships (aiModels); the scene script says how each one plays. Game 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 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 has a complete one.
  • A scripted bot that searches: write a RulesKernel with a heuristic, then call ai.mcts 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, 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 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 file, read it with world.readData, and build the bot's deck when it sits down — the deck the room's owner chose in an unranked lobby is PlayerInfo.deckId, 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 and api.listBots. A mod cannot register a bot or move one, and has no ai.mcts.

See also#

  • Game bots — scripted and AI bots, hardware tiers, host migration, hidden information.
  • Manifest ▸ Bots — declaring bots and importing a model in the editor.
  • Training a bot model — from a rules kernel to an .onnx file.
  • Host authority — why a script, and therefore a bot, runs on one peer.
  • 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.

TypeScript
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, to scale your effort. In a manifest, a model declares the weakest tier that should host it as minTier (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#

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) and an "abort" event to listen for. Every bot turn gets one, as ctx.signal.

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 and ai.evaluate, 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#

Members#

Signature Description Returns
aborted true once the bot should stop thinking. boolean
addEventListener(type: "abort", listener: () => void) Run listener when the signal fires. void
removeEventListener(type: "abort", listener: () => void) Stop a listener added with addEventListener. void

botabortsignal.aborted#

TypeScript
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#

TypeScript
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

TypeScriptbotabortsignal.addEventListener.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 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#

TypeScript
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. 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

TypeScriptbotabortsignal.removeEventListener.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

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, 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#

TypeScriptruleskernel.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.

See also#

Members#

Signature Description Returns
id Stable id of this rule set, e.g. "reversi". Recorded in every training game. string
version Bump when the rules, encode or the policy layout change: old models are then stale. number
initial(seats: string[], setup?: unknown) The starting position for these seats, in turn order. S
currentPlayer(s: S) The seat to move. Only meaningful while terminal returns null. string
legalMoves(s: S) 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) The position after m. Returns a NEW state and leaves s and m untouched. S
terminal(s: S) 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) The position as model input, seen by the player to move. Length = planes x height x width. Float32Array
encoding The shape of encode's output: planes stacked height x width grids, row by row. { planes: number; height: number; width: number }
policySize Length of the policy vector: one slot per move the game can ever offer, pass included. number
moveIndex(m: M) A move's slot in the policy vector, from 0 to policySize - 1. Different legal moves get different slots. number
moveAt(i: number) The move a policy slot stands for: moveAt(moveIndex(m)) equals m. M
heuristic(s: S) 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#

TypeScript
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#

TypeScript
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.initial#

TypeScript
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 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 shuffles two decks from a seed this way.

Example

TypeScriptruleskernel.initial.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#

TypeScript
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 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

TypeScriptruleskernel.currentPlayer.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.legalMoves#

TypeScript
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

TypeScriptruleskernel.legalMoves.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.apply#

TypeScript
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

TypeScriptruleskernel.apply.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.terminal#

TypeScript
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

TypeScriptruleskernel.terminal.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.encode#

TypeScript
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

TypeScriptruleskernel.encode.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.

See also

ruleskernel.encoding#

TypeScript
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'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.policySize#

TypeScript
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 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.

See also

ruleskernel.moveIndex#

TypeScript
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. 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

TypeScriptruleskernel.moveIndex.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#

TypeScript
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.

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

TypeScriptruleskernel.moveAt.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.heuristic#

TypeScript
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 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

TypeScriptruleskernel.heuristic.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

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 searches: a kernel, a state, how many simulations to run and, optionally, a modelId — plus four tuning knobs that are best left at their defaults until you have measured a reason to change them, and playerCount 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#

Members#

Signature Description Returns
kernel The rules to search with. RulesKernel<S, M>
modelId The id of a model declared in the manifest's aiModels. It supplies the policy and the value. string | null
state The position to search from. It must not be over. S
simulations Playouts to run. More is stronger and slower: this is the usual difficulty dial. number
cPuct How readily the search tries moves the model rates low. Default 1.5. number
temperature 0 (the default) plays the most-visited move; above 0 picks at random, weighted by visits. number
dirichlet Extra randomness at the first move of the search, for training games. Leave unset at the table. { alpha: number; epsilon: number }
batchSize Positions sent to the model per call. Default 8, at most 256. number
playerCount 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 Stops the search early. Pass the signal your bot's onTurn was given. BotAbortSignal

mctsspec.kernel#

TypeScript
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.

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

mctsspec.modelId#

TypeScript
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, 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

mctsspec.state#

TypeScript
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. 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.

See also

mctsspec.simulations#

TypeScript
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 says how many finished.

See also

mctsspec.cPuct#

TypeScript
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.temperature#

TypeScript
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

mctsspec.dirichlet#

TypeScript
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.batchSize#

TypeScript
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

mctsspec.playerCount#

TypeScript
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

mctsspec.signal#

TypeScript
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

MctsResult#

Surface A — table script · interface · 4 members

What ai.mcts resolves with.

What ai.mcts resolves with: the move 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#

Members#

Signature Description Returns
move The chosen move. M
policy How the search spread its visits over the policy vector (sums to 1). Float32Array
value Estimated result in -1..1 for the player to move. number
visits Playouts that finished. number

mctsresult.move#

TypeScript
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

mctsresult.policy#

TypeScript
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

mctsresult.value#

TypeScript
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#

TypeScript
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

BotTurnContext#

Surface A — table script · interface · 7 members

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

What onTurn is told about the turn it has to play: which bot (peerId, botId), where it sits (seat), how hard it plays (difficultyId, and difficulty with the dials your manifest declares for it), the model an AI bot plays with at that difficulty (modelId), and a signal 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#

Members#

Signature Description Returns
peerId The bot's peer id (it starts with bot:): the id onTurnStarted and world.getPlayers() report for it. string
seat The seat the bot holds, or null while it has none. string | null
botId The bot's id from the manifest's bots list. string
difficultyId The difficulty the bot is playing at, or null when it declares none. string | null
modelId 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 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 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#

TypeScript
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, 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.

See also

botturncontext.seat#

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

botturncontext.botId#

TypeScript
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 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#

TypeScript
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, 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, 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.

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

botturncontext.modelId#

TypeScript
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 or ai.evaluate: the difficulty's own modelId 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#

TypeScript
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 | null. For an AI bot the dials (simsPerMove, temperature, params) come from its model's difficulties 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.signal#

TypeScript
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 and ai.evaluate, 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. Checking aborted before you move is still how a handler stops cleanly instead of failing.

See also

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

Members#

Signature Description Returns
id The handler id of a bot declared in the manifest's bots list. string
onTurn 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 Called when one of these bots takes a seat. (player: PlayerInfo) => void
onRemoved Called when one of these bots leaves the table. (player: PlayerInfo) => void

botregistration.id#

TypeScript
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 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

botregistration.onTurn#

TypeScript
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 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 or with ai.evaluate), 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, 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.

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) is refused with an AbortError; see BotAbortSignal.

See also

botregistration.onAdded#

TypeScript
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. 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#

TypeScript
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. 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

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.

TypeScript
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, 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 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#

Members#

Signature Description Returns
register(bot: BotRegistration) Register the handler for a bot declared in the manifest's bots list. One registration per id. void
list() The bots seated right now: world.getPlayers() narrowed to the entries with isBot. PlayerInfo[]
skipTurn(peerId: string, reason: string) 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#

TypeScript
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 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 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 and return. A skip is not a failure and does not count towards the three.

Example

TypeScriptbots.register.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

bots.list#

TypeScript
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() narrowed to the entries whose isBot is true. Synchronous — answered from the roster the host pushes into the sandbox.

Returns

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' peerIds — or simply check that the id starts with bot:, which every bot's peer id does and no person's can.

Example

TypeScriptbots.list.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

bots.skipTurn#

TypeScript
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

TypeScriptbots.skipTurn.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

AiTensor#

Surface A — table script · type

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

TypeScript
declare type AiTensor = Float32Array | BigInt64Array;

One model input as ai.evaluate 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#

AiEvaluateOptions#

Surface A — table script · interface · 1 members

Options for one ai.evaluate call.

The optional third argument to ai.evaluate: a signal to give up early. (A call takes no difficulty — a bot's difficulty reaches its script as ctx.difficulty.)

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#

Members#

Signature Description Returns
signal Gives up on the call when it fires. Pass the signal your bot's onTurn was given. BotAbortSignal

aievaluateoptions.signal#

TypeScript
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

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.

TypeScript
declare const ai: Ai;

The model-inference singleton.

The ai global: search a position with your own rules (ai.mcts), run one of your game's declared models directly (ai.evaluate), and read what the host's device can do (ai.tier).

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 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, 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 — what ai.mcts searches.
  • Game bots — hardware tiers, model sizes and what to expect from a model.
  • api.ai.evaluate — the mod surface's version of ai.evaluate.

Members#

Signature Description Returns
evaluate(modelId: string, inputs: Record<string, AiTensor>, opts?: AiEvaluateOptions) 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() 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>) 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#

TypeScript
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.
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 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 instead: it calls this for you, in batches, and falls back to your heuristic when the model is unavailable.

Example

TypeScriptai.evaluate.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 — 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

ai.tier#

TypeScript
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: "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

TypeScriptai.tier.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

ai.mcts#

TypeScript
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 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> kernel, state, simulations, and optionally modelId, cPuct, temperature, dirichlet, batchSize, playerCount, signal.

Returns

Promise<MctsResult<M>> — move to play, the visit distribution as policy, the search's value estimate for the side to move, and how many playouts finished (visits).

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; pass ctx.modelId 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

TypeScriptai.mcts.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

Did this page answer your question?