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.registerhas a complete one. - A scripted bot that searches: write a
RulesKernelwith aheuristic, then callai.mctswithout amodelId. The measured result is that this alone plays strongly. - An AI bot: the same search, with the
modelIdof a model your manifest declares — passctx.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
gameDecksfile, read it withworld.readData, and build the bot's deck when it sits down — the deck the room's owner chose in an unranked lobby isPlayerInfo.deckId, andnullmeans 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
.onnxfile. - Host authority — why a script, and therefore a bot, runs on one peer.
- Types —
PlayerInfo, which carriesisBot,botId,difficultyIdanddeckId.
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.
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#
ai.tier— the host's tier, read from a script.- Game bots: hardware tiers — what each tier can host, with measured numbers.
BotAbortSignal#
Surface A — table script · interface · 3 members
The part of an abort signal a bot can use: a flag to poll and an "abort" event.
Scripts have no DOM, so this is not the browser's own abort-signal type, but these three members behave the same way.
The part of an abort signal a bot can use: a flag to poll (aborted) 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#
BotTurnContext.signal— where you get one.MctsSpec.signalandAiEvaluateOptions.signal— where to pass it.
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#
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— be told instead of polling.
botabortsignal.addEventListener#
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
// 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— clean up when the turn ends normally.BotAbortSignal.aborted— the flag the same event sets.
botabortsignal.removeEventListener#
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
// content/scripting-api/examples/botabortsignal.removeEventListener.ts
// Scene script: show a "thinking..." label for as long as a bot is thinking,
// and take it down whichever way the turn ends.
//
// The listener is only needed while the turn runs. Remove it when the turn is
// over, so a signal that fires later does not reach a label you already took
// down for a different reason.
bots.register({
id: "thinker",
async onTurn(ctx) {
const labelId = `thinking-${ctx.peerId}`;
const clear = () => world.removeTextLabel(labelId);
world.setTextLabel({ id: labelId, text: "thinking...", position: [0, 0, 2] });
ctx.signal.addEventListener("abort", clear);
try {
await world.wait(1);
} finally {
ctx.signal.removeEventListener("abort", clear);
clear();
}
}
});
Gotchas
Pass the same function, not a copy. An arrow function written out again is a different function and removes nothing; keep the one you added in a variable.
See also
BotAbortSignal.addEventListener— the other half.
RulesKernel#
Surface A — table script · interface · 13 members
A game's rules as pure functions over plain data: the one description a bot's search, the trainer and your own script all share.
S (a state) and M (a move) must be plain JSON, and nothing here may touch world,
read a clock or roll a random number. A kernel knows nothing about the table: your script
builds an S from what is on the table and turns the chosen M into ordinary script calls.
Your game's rules as pure functions over plain data — the one description that ai.mcts, 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#
// 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#
ai.mcts— searching with a kernel.- Training a bot model — what a kernel's
encodeand policy layout are for. - Game bots — scripted versus AI bots.
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#
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— what to change instead.
ruleskernel.version#
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.encodeandRulesKernel.moveIndex— the two layouts a model depends on.
ruleskernel.initial#
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
// 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— reading the seat to move back out of the state.
ruleskernel.currentPlayer#
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
// content/scripting-api/examples/ruleskernel.currentPlayer.ts
// Scene script: currentPlayer() for a game where a capture earns another move.
//
// Nothing assumes turns alternate. The search credits every move to the seat
// currentPlayer() names for the position it was played from, so a seat that
// moves twice in a row is scored correctly - as long as this answer is right.
interface Captures {
seats: [string, string];
/** Index of the seat to move. apply() leaves it unchanged after a capture. */
toMove: 0 | 1;
stones: number[];
}
const currentPlayer: RulesKernel<Captures, number>["currentPlayer"] = (s) => s.seats[s.toMove];
const position: Captures = { seats: ["light", "dark"], toMove: 1, stones: [3, 0, 4] };
world.log(`to move: ${currentPlayer(position)}`);
Gotchas
Name a seat that exists. The trainer's check refuses a kernel whose currentPlayer returns a seat that is not
in the game.
See also
RulesKernel.encode— written from this seat's point of view.RulesKernel.heuristic— scored for this seat.
ruleskernel.legalMoves#
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
// content/scripting-api/examples/ruleskernel.legalMoves.ts
// Scene script: legalMoves() for a placement game in which a player with no
// placement must pass.
//
// The list is never empty for a position that is not over. "I have nothing to
// do" is a move like any other - PASS here - with its own policy slot, so the
// search can play it and a model can learn when it is forced.
const SIZE = 8;
const PASS = SIZE * SIZE;
interface Grid {
cells: number[];
toMove: 1 | -1;
}
/** Your own rule for whether `cell` is a legal placement for the side to move. */
function canPlace(grid: Grid, cell: number): boolean {
const row = Math.floor(cell / SIZE);
return grid.cells[cell] === 0 && (row === 0 || grid.cells[cell - SIZE] !== 0);
}
const legalMoves: RulesKernel<Grid, number>["legalMoves"] = (grid) => {
const moves: number[] = [];
for (let cell = 0; cell < PASS; cell += 1) {
if (canPlace(grid, cell)) {
moves.push(cell);
}
}
// terminal() decides when the game is over; here, a full grid. Anything
// short of that gets at least the pass.
return moves.length > 0 ? moves : [PASS];
};
const empty: Grid = { cells: new Array<number>(PASS).fill(0), toMove: 1 };
world.log(`${legalMoves(empty).length} opening placements`);
Gotchas
Empty means "this game is broken". The search rejects a non-terminal position with no legal move, and the
trainer's check reports it as no-legal-moves. If your game can leave a player stuck, either make that a pass or
make it terminal.
Two legal moves must not share a policy slot. The trainer's check reports move-index-duplicate.
Moves must survive JSON unchanged — plain objects, strings and numbers. Training games are stored as JSON.
See also
RulesKernel.moveIndex— every move here needs a slot.RulesKernel.terminal— the other way a position can have no move.
ruleskernel.apply#
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
// content/scripting-api/examples/ruleskernel.apply.ts
// Scene script: apply() for a pile game, written so that it never changes
// what it was given.
//
// The search keeps thousands of states alive at once and applies many moves
// to the SAME one. An apply() that edits its input in place corrupts every
// branch that shares it - the bot then plays nonsense with no error anywhere.
// The trainer's kernel check refuses a kernel that does this.
interface Piles {
piles: number[];
seats: [string, string];
toMove: 0 | 1;
}
interface Take {
pile: number;
count: number;
}
const apply: RulesKernel<Piles, Take>["apply"] = (s, move) => {
const piles = s.piles.slice(); // a copy - never `s.piles[move.pile] -= ...`
piles[move.pile] -= move.count;
return { piles, seats: s.seats, toMove: s.toMove === 0 ? 1 : 0 };
};
const before: Piles = { piles: [3, 4, 5], seats: ["a", "b"], toMove: 0 };
const after = apply(before, { pile: 2, count: 2 });
world.log(`${before.piles.join(",")} -> ${after.piles.join(",")}`);
Gotchas
Mutating the input corrupts the search silently. Every branch that shared the state now sees the move, and
the bot plays nonsense without an error. The trainer's check catches it (apply-mutates-input); the table does
not.
Same state and move, same result. No randomness, no clock — apply-not-deterministic otherwise. A game with a
random element (a shuffled draw) does not fit a kernel as-is.
The result must survive JSON unchanged: no Map, Set, class instance or function inside a state.
See also
RulesKernel.currentPlayer— who moves in the new state.
ruleskernel.terminal#
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
// content/scripting-api/examples/ruleskernel.terminal.ts
// Scene script: terminal() for a race to ten points, for any number of seats.
//
// null while the game is on. When it is over: every seat's result in -1..1 -
// 1 a win, 0 a draw, -1 a loss, and anything between for a placing. Name
// EVERY seat; a seat missing from the record has no result to learn from.
interface Score {
points: Record<string, number>;
seats: string[];
toMove: number;
}
const TARGET = 10;
const terminal: RulesKernel<Score, number>["terminal"] = (s) => {
const best = Math.max(...s.seats.map((seat) => s.points[seat] ?? 0));
if (best < TARGET) {
return null;
}
const leaders = s.seats.filter((seat) => (s.points[seat] ?? 0) === best);
const value: Record<string, number> = {};
for (const seat of s.seats) {
// A shared first place is a draw between the leaders; everyone else lost.
value[seat] = leaders.includes(seat) ? (leaders.length === 1 ? 1 : 0) : -1;
}
return { value };
};
world.log(JSON.stringify(terminal({ points: { a: 10, b: 7, c: 10 }, seats: ["a", "b", "c"], toMove: 1 })));
Gotchas
Every seat, exactly once. A result that leaves a seat out (or adds one) is reported by the trainer's check as
terminal-value-seats; a value outside -1..1 as terminal-value-range.
A game must end. Training cuts a game off at a ply cap and records it as a draw; the trainer's check reports a kernel whose games never finish.
See also
RulesKernel.legalMoves— never empty while this isnull.
ruleskernel.encode#
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
// 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— the shape this has to fill.manifest.aiModels[].inputs— a model's input is this, batched.
ruleskernel.encoding#
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.encode— what fills it.ai.mcts— how a model is matched to a kernel.
ruleskernel.policySize#
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— numbering the moves into these slots.
ruleskernel.moveIndex#
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
// 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— the inverse, which must agree with this.
ruleskernel.moveAt#
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
// content/scripting-api/examples/ruleskernel.moveAt.ts
// Scene script: moveAt(), the inverse of moveIndex(), and the round trip that
// proves the two agree.
//
// moveAt(moveIndex(m)) must equal m for every legal move. The search reads a
// policy slot back into a move with it, so a slot that comes back as a
// different move makes the bot play something it never searched.
const SIZE = 8;
const PASS_SLOT = SIZE * SIZE;
type Placement = { row: number; col: number } | "pass";
const moveIndex: RulesKernel<unknown, Placement>["moveIndex"] = (move) =>
move === "pass" ? PASS_SLOT : move.row * SIZE + move.col;
const moveAt: RulesKernel<unknown, Placement>["moveAt"] = (index) =>
index === PASS_SLOT ? "pass" : { row: Math.floor(index / SIZE), col: index % SIZE };
// The round trip, over every slot. Worth keeping in your own script while you
// develop a kernel; the trainer runs the same check before any self-play.
for (let slot = 0; slot <= PASS_SLOT; slot += 1) {
if (moveIndex(moveAt(slot)) !== slot) {
world.log(`slot ${slot} does not round-trip`);
}
}
Gotchas
"Equals" means the same JSON. The trainer's check compares moveAt(moveIndex(m)) with m as data and reports
move-index-round-trip when they differ — including when a property is in a different order or an extra field
appears.
See also
RulesKernel.policySize— the range ofi.
ruleskernel.heuristic#
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
// content/scripting-api/examples/ruleskernel.heuristic.ts
// Scene script: heuristic() for a capture game - a quick guess at who is
// winning, used by ai.mcts whenever there is no model to ask.
//
// Return a number in -1..1 for the SIDE TO MOVE: positive when it is ahead.
// It is called a great many times per move, so keep it to a few arithmetic
// operations - no search inside it. A search with only this and no model is
// already strong in practice; a model has to beat it to be worth shipping.
interface Material {
/** Pieces left, per seat. */
pieces: Record<string, number>;
seats: [string, string];
toMove: 0 | 1;
}
const heuristic: NonNullable<RulesKernel<Material, number>["heuristic"]> = (s) => {
const me = s.seats[s.toMove];
const them = s.seats[s.toMove === 0 ? 1 : 0];
const mine = s.pieces[me] ?? 0;
const theirs = s.pieces[them] ?? 0;
if (mine + theirs === 0) {
return 0;
}
// (mine - theirs) / total is already in -1..1.
return (mine - theirs) / (mine + theirs);
};
world.log(`ahead by ${heuristic({ pieces: { red: 9, blue: 6 }, seats: ["red", "blue"], toMove: 0 }).toFixed(2)}`);
Gotchas
Without one, a host that cannot run your model plays random legal moves. Every kernel used by an AI bot should have a heuristic for exactly that case.
Cheap, and from the mover's side. It is called for many positions per move, so no search inside it. A value
for the wrong side makes the bot play to lose. Outside -1..1 (or not finite) is clamped at the table and reported
by the trainer's check as heuristic-range.
See also
ai.mcts— how the search uses it.- Training a bot model — measuring a model against it.
MctsSpec#
Surface A — table script · interface · 10 members
What ai.mcts searches: a kernel, a position, how hard to think and, optionally, a model.
What ai.mcts 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#
ai.mcts— the search.MctsResult— what comes back.
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#
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
RulesKernel— writing one.
mctsspec.modelId#
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
RulesKernel.heuristic— what the search uses instead.ai.mcts— the input and output shape a model must have.
mctsspec.state#
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
RulesKernel— the state's shape is yours.
mctsspec.simulations#
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
BotTurnContext.difficulty— the declaredsimsPerMoveto pass.ai.tier— scaling it by the host's device.
mctsspec.cPuct#
cPuct?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How readily the search tries moves the model rates low. Default 1.5.
How readily the search tries moves the model (or heuristic) rates low. Default 1.5.
Returns
number | undefined. Higher explores more; lower trusts the prior more.
How, why and when to use it
Leave it alone unless you are tuning a model and measuring the result. The same value is used when models are trained, and searching at the table with a different one plays a slightly different game than the model learned.
Gotchas
It is not a difficulty setting. Change simulations or temperature to make a bot weaker or stronger.
See also
MctsSpec.simulations— the dial to turn instead.
mctsspec.temperature#
temperature?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
0 (the default) plays the most-visited move; above 0 picks at random, weighted by visits.
How the final move is picked from the search's visit counts. 0, the default, plays the most-visited move; above 0 picks at random, weighted by visits — the higher, the more varied.
Returns
number | undefined.
How, why and when to use it
For a bot that should not play the same game twice, or an "easy" level that makes believable mistakes: a small temperature (0.3–1) on the opening moves, 0 afterwards, is the common pattern.
Gotchas
Randomness is not the same as weakness. A high temperature makes occasional terrible moves rather than consistently mediocre ones. Fewer simulations is the smoother way to weaken a bot.
See also
MctsResult.policy— the distribution it samples from.
mctsspec.dirichlet#
dirichlet?: { alpha: number; epsilon: number };
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Extra randomness at the first move of the search, for training games. Leave unset at the table.
Extra randomness mixed into the search's first move, for training games: alpha shapes the noise, epsilon is
how much of it is mixed in.
Returns
{ alpha: number; epsilon: number } | undefined.
How, why and when to use it
Self-play training uses it so a model sees varied positions. At the table, leave it unset.
Gotchas
It makes the bot worse on purpose. It pushes the search towards moves it would not otherwise consider.
See also
MctsSpec.temperature— the table's way to vary a bot's play.
mctsspec.batchSize#
batchSize?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Positions sent to the model per call. Default 8, at most 256.
How many positions the search sends to the model per call. Default 8, at most 256.
Returns
number | undefined.
How, why and when to use it
On the processor a model's cost per position barely changes with the batch, so the default is fine. On a
graphics card each call has a fixed cost of several milliseconds and batching is the whole win — for a model
declared minTier: "gpu", try 32 or 64. Without a model it has no effect.
Gotchas
A bigger batch is a less precise search for the same number of simulations, because the positions in one batch are chosen before any of their results are known.
See also
- Game bots: hardware tiers — the measured per-call costs.
mctsspec.playerCount#
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#
signal?: BotAbortSignal;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Stops the search early. Pass the signal your bot's onTurn was given.
Stops the search early. Pass the signal your bot's onTurn was given.
Returns
BotAbortSignal | undefined.
How, why and when to use it
When it fires, the search resolves with the best move it has found once at least one playout has finished, and
rejects with an error named "AbortError" if none has. Its model calls are cancelled too.
Gotchas
Resolving is not permission to move. A search that stopped because the turn ran out still hands you a move;
check ctx.signal.aborted before playing it.
See also
BotTurnContext.signal— where it comes from.
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#
ai.mcts— the search.
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#
move: M;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The chosen move.
The chosen move — one of kernel.legalMoves(state).
How, why and when to use it
Turn it into script calls: spawn or move the piece, flip the card. That translation is your script's job; the search knows nothing about the table.
Gotchas
Check the turn is still yours first (ctx.signal.aborted), especially after a long search.
See also
RulesKernel.apply— what the move means to your rules.
mctsresult.policy#
policy: Float32Array;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How the search spread its visits over the policy vector (sums to 1).
How the search spread its visits over the policy slots: a Float32Array of policySize values that sum to 1.
Slot i is the move kernel.moveAt(i).
How, why and when to use it
For a "top three moves" display or a hint, read the largest entries. It is also what training learns from.
Gotchas
A forced move reads 1 in its slot and 0 elsewhere — no search was run for it.
See also
RulesKernel.moveIndex— the slot numbering.
mctsresult.value#
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— how much evidence it rests on.
mctsresult.visits#
visits: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Playouts that finished.
How many playouts finished.
How, why and when to use it
Compare it with the simulations you asked for. Consistently fewer means the search is being cut off by the
turn's time budget on this host — lower the simulations, or raise the budget with params.turnBudgetMs.
Gotchas
0 means a forced move, answered without searching.
See also
MctsSpec.simulations— what you asked for.
BotTurnContext#
Surface A — table script · interface · 7 members
What a bot's onTurn is told about the turn it has to play.
What onTurn 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#
BotRegistration.onTurn— where you receive it.PlayerInfo— the same bot asworld.getPlayers()describes it.
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#
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.botId— which manifest bot it is.
botturncontext.seat#
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
world.getSeatZones— where the seat's zones are.
botturncontext.botId#
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— how hard it should play.
botturncontext.difficultyId#
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
manifest.bots[].difficulties— declaring them.MctsSpec.simulations— the usual thing to scale.
botturncontext.modelId#
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— that model's dials for this bot's difficulty.MctsSpec.modelId— where it goes.
botturncontext.difficulty#
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.difficultyId— the same difficulty's id.MctsSpec.simulationsandMctsSpec.temperature— where the dials go.
botturncontext.signal#
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
BotAbortSignal— its three members.BotRegistration.onTurn— the time budget.
BotRegistration#
Surface A — table script · interface · 4 members
The handler a script registers for one of its game's bots.
What you pass to bots.register: the handler id a manifest bot names, the onTurn that
plays its turns, and two optional notifications for when one of those bots sits down or leaves.
How, why and when to use it#
Write it inline, as an object literal inside the call — the publish scanner reads id straight out of that
literal. The three functions are kept inside your script's sandbox; the host only ever learns the id.
See also#
BotRegistration.onTurn— the one that matters.BotTurnContext— whatonTurnis handed.
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#
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
bots.register— duplicates, and when "no handler" is decided.- Manifest ▸ Bots — the Handler and Registered fields.
botregistration.onTurn#
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
bots.register— a complete example.BotTurnContext.signal— how you learn the time is up.
botregistration.onAdded#
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— the other end.bots.list— every bot seated right now.
botregistration.onRemoved#
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
BotRegistration.onAdded— the other end.globalEvents.onPlayerLeft— fires for every departure, bots included.
Bots#
Surface A — table script · interface · 3 members
The game's bots: register the code that plays them, and see which are seated.
Reached from a script as the injected global bots.
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#
BotRegistration— what you pass toregister.BotTurnContext— whatonTurnis told.- Game bots — the concept page.
manifest.bots— declaring a bot.
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#
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
// 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
BotRegistrationandBotTurnContext— the two shapes involved.bots.skipTurn— passing on purpose.ai.mcts— searching for the move instead of picking it by hand.manifest.bots[].handler— the id this registers.- What gets rejected —
bot-handler-missing.
bots.list#
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
// content/scripting-api/examples/bots.list.ts
// Scene script: say who the opponents are when the game starts, and keep a
// "bots at this table" label up to date as bots come and go.
//
// bots.list() is world.getPlayers() narrowed to the bots, so the same entries
// carry botId (which manifest bot it is) and difficultyId (how hard it plays).
function describeBots(): string {
const seated = bots.list();
if (seated.length === 0) {
return "No bots at this table";
}
return seated
.map((bot) => `${bot.displayName ?? bot.botId}${bot.difficultyId ? ` (${bot.difficultyId})` : ""}`)
.join(", ");
}
function showBots(): void {
world.setTextLabel({ id: "bot-roster", text: describeBots(), position: [0, 0, -3] });
}
showBots();
// A bot joining or leaving is reported like any other player.
globalEvents.onPlayerJoined.add((player) => {
if (player.isBot) {
showBots();
}
});
globalEvents.onPlayerLeft.add((player) => {
if (player.isBot) {
showBots();
}
});
Gotchas
It is a copy, refreshed when the roster changes. Keep calling it rather than holding the array: a bot can be added, removed or swapped by the host at any time from Settings ▸ Bots.
A paused bot is still listed. PlayerInfo has no status field, so a bot the host paused (its device cannot
run the model, or its script kept failing) looks the same here as one that is playing. Its turns are skipped by
the table either way.
seat can be null. A player can take a bot's seat at the table; the bot stays, paused and seatless.
Bots arrive through the ordinary player events. globalEvents.onPlayerJoined and onPlayerLeft fire for
bots too, with isBot: true, so you do not need to poll this.
See also
PlayerInfo.isBot— the flag this filters on.world.getPlayers— everyone, people and bots.api.listBots— the mod surface's version, which does carrystatus.
bots.skipTurn#
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
// content/scripting-api/examples/bots.skipTurn.ts
// Scene script: a bot that plays one card a turn onto the discard pile, and
// passes - with a reason every player sees - when it has none to play.
//
// Cards dealt to a seat carry the tag "hand-<seat>". The discard pile is a
// snap point labelled "discard".
bots.register({
id: "card-player",
async onTurn(ctx) {
if (!ctx.seat) {
// A bot can be seated-less for a moment (a player took its chair).
await bots.skipTurn(ctx.peerId, "it has no seat");
return;
}
const hand = await world.getAllObjects({ kind: "card", tag: `hand-${ctx.seat}` });
const discard = (await world.getSnapPoints()).find((point) => point.label === "discard");
if (hand.length === 0 || !discard) {
// A pass on purpose. This is not counted against the bot: three FAILED
// turns in a row pause it, and a skip is not a failure.
await bots.skipTurn(ctx.peerId, hand.length === 0 ? "no cards left to play" : "there is no discard pile");
return;
}
const card = hand[0];
card.setPosition(discard.position);
card.flip();
// Returning ends the turn. Nothing else to call.
}
});
Gotchas
A game whose rules have a pass move should play the pass, not skip. If passing is part of your game (and a
rules kernel requires it to be a move), make the pass on the table the way a person would; skipTurn is for
"this bot cannot take part this turn".
Skipping only moves the turn on when it is the bot's turn. Called for a bot that is not the active player, it posts the notice and changes nothing else.
Refused on a peer. Only the host plays bots; on any other peer the promise rejects.
See also
bots.register— whereonTurnand itsctx.peerIdcome from.BotTurnContext.peerId— the id to pass.
AiTensor#
Surface A — table script · type
One model input: 32-bit floats, or 64-bit integers for a model that takes indices.
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#
ai.evaluate— the call that takes these.manifest.aiModels[].inputs— the declared names, shapes and types.
AiEvaluateOptions#
Surface A — table script · interface · 1 members
Options for one ai.evaluate call.
The optional third argument to ai.evaluate: 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#
AiEvaluateOptions.signal.BotTurnContext.signal— the signal to pass.
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#
signal?: BotAbortSignal;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Gives up on the call when it fires. Pass the signal your bot's onTurn was given.
Gives up on the call when the signal fires: the promise rejects with ai-budget-exceeded (as an error named
"AbortError") and the host stops waiting for the worker.
Returns
BotAbortSignal | undefined. Leave it out and the call is bounded only by its own 2-second budget.
How, why and when to use it
Pass the signal your bot's onTurn was given. A turn that times out, or a bot the host removes mid-thought,
then cancels its model calls instead of finishing them for nothing.
Gotchas
An already-fired signal rejects immediately, without sending anything to the host.
The rejection uses the time-out code. From your script's side a cancelled call and a call that ran out of
time are the same thing — the answer did not come in time — so both are ai-budget-exceeded.
See also
BotTurnContext.signal— where the signal comes from.BotAbortSignal— what it is.
Ai#
Surface A — table script · interface · 3 members
Model-backed bots: run a model the manifest declares, and search with it.
Reached from a script as the injected global ai.
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— whatai.mctssearches.- Game bots — hardware tiers, model sizes and what to expect from a model.
api.ai.evaluate— the mod surface's version ofai.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#
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
// 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
AiTensorandAiEvaluateOptions— what goes in.ai.mcts— a search that calls this for you.manifest.aiModels[].inputs— the declaration your inputs are checked against.api.ai.evaluate— the same call on the mod surface.
ai.tier#
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
// content/scripting-api/examples/ai.tier.ts
// Scene script: think harder on a host that can afford it.
//
// ai.tier() is the HOST's device class, as the hardware probe measured it -
// the scripts run on the host, so that is the machine doing the thinking.
// It never changes what the bot is allowed to do; it only helps it budget.
function playoutsForThisHost(base: number): number {
switch (ai.tier()) {
case "gpu":
return base * 4;
case "cpu-fast":
return base * 2;
case "cpu-basic":
return base;
default:
// "none": this device cannot run a model, or it has not been measured yet
// (the host is measured when an AI bot is seated). A search with no model
// still works, so keep it cheap rather than giving up.
return Math.max(16, Math.floor(base / 2));
}
}
world.log(`bots on this table search with ${playoutsForThisHost(64)} playouts a move (${ai.tier()})`);
Gotchas
"none" also means "not measured". The check downloads the inference runtime, so a device is only measured
when an AI bot is seated at a table it hosts, or when its player tests it in Settings ▸ Graphics; the result is
kept for a week. A host that has never been measured reads "none" even on a fast machine.
It is self-reported. A tier is what the host's own browser measured. It is advisory: it decides what the lobby allows and whom the server prefers as the next host, and it grants nothing.
It describes the host, which can change. After a host migration the scripts restart on the new host and read its tier.
See also
HardwareTier— what each tier means.- Game bots: hardware tiers — how tiers gate hosting, and the measured costs.
manifest.aiModels[].minTier— what a model asks for.
ai.mcts#
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
// 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
MctsSpecandMctsResult— the shapes in and out.RulesKernel— what you have to write first.RulesKernel.heuristic— the brain of a model-less search.- Training a bot model — measuring whether a model beats the heuristic.
