Skip to main content
Worker-safe. The bot package lets a developer drop a traditional game AI into any seat of any openturn game without touching the engine. The same defineBot({ decide }) shape powers a five-line random bot, a heuristic bot, and an alpha-beta minimax bot.

Install

The package depends on @openturn/core and @openturn/client. The game itself is untouched — bots consume the public session API.

Authoring a bot

defineBot(bot)

Identity function that names and types your bot.
The full bot shape:
actionDelayMs is presentation pacing only — the runner waits at least this long between picking an action and dispatching it so human clients can see bot turns unfold. It is independent of thinkingBudgetMs (which bounds decide). decide may be sync (random, heuristic) or async (MCTS with yields, network calls). The runner awaits whatever you return.

DecideContext<TGame>

  • view is what session.getPlayerView(playerID) returns. Hidden information is already filtered out.
  • snapshot is the full server-side snapshot. It is null on hosted (network) hosts because clients only see views.
  • legalActions is enumerated for you — see Legal-action enumeration below.
  • rng is reproducible: bots seeded from the same snapshot make the same choices.
  • signal aborts when the snapshot moves on while you are thinking. Bots that make network calls should pass it to fetch so a stale request exits early.

LegalAction

payload is typed as unknown rather than JsonValue so authors can pass concrete move-arg types (e.g. PlaceMarkArgs) without adding index signatures. The engine validates JSON shape at dispatch time. The runner needs to know which moves a seat may legally play. Enumeration resolves in this order:
  1. Game-level hook (preferred). Add legalActions to defineGame:
    The engine never reads this field — only the bot runtime does.
  2. Per-bot fallback. If the game has no hook, the bot supplies its own:
  3. Empty. If neither exists, legalActions is [] and your decide must use simulate to explore the action space.

enumerateLegalActions(game, snapshot, view, playerID, bot)

Direct call, normally unnecessary because the runner does this for you. Useful inside MCTS-style searches that re-enumerate from a simulated snapshot.

Simulating moves

simulate(game, snapshot, playerID, action) => SimulateResult

Dry-run a candidate action against a clone of the snapshot. The original snapshot is never mutated.
The cloned session is created with skipValidation: true because the live session was already validated. RNG resumes from snapshot.meta.rng, so MCTS rollouts are deterministic given a forked seed. SimulateFn is also handed to decide via context.simulate(action) — same function, with game, snapshot, and playerID already bound for you. simulate is unavailable on hosted hosts (the bot doesn’t have the full snapshot or the game definition over the wire). The function returns { ok: false, reason: "simulate_unavailable_for_host" } in that case.

Attaching to a session

attachLocalBot(options) — single seat

The returned session is a session-shaped facade. Use it instead of the raw session in your game loop — every applyEvent through it notifies the runner. Bot 1 watches for its turn and dispatches autonomously; the human side of the loop calls session.applyEvent("0", ...) exactly as before.
When binding more than one bot to the same session, prefer attachLocalBots (next), or pass the returned bus to subsequent attachLocalBot calls — every host on the same bus hears every dispatch.

attachLocalBots(options) — many seats at once

whenIdle(playerID) resolves once the bot at that seat has finished thinking and dispatched (or errored).

attachHostedBot(options) — over the network

For cloud play, a separate process connects to the room as a player and runs the bot:
The runner subscribes to the client’s snapshot stream and dispatches via client.dispatchEvent. simulate is unavailable; bots that need search must rely on a per-bot enumerate and pure-view heuristics. The recommended cloud topology is documented in Concepts: AI bots.

BotRunner

detach removes the runner’s subscription and aborts any in-flight decide. Call it when the match ends or the player is replaced.

AttachOptions

Shared by every attach* call:

Lifecycle and cancellation

The session reducer is synchronous; decide may be slow. The runner separates them:
  1. On every snapshot change, if it is your seat’s turn and no decision is in flight, decide fires inside a microtask.
  2. While decide is awaiting, every new snapshot aborts signal and queues a fresh decision once the in-flight one settles.
  3. After decide resolves, the runner re-checks position.turn. If the turn has advanced (e.g. the opponent reconnected and the room re-synced), the decision is dropped before dispatch.
  4. host.dispatch(action) returns the engine’s outcome. Invalid moves never mutate state, so onError is called and the runner waits for the next change to retry.
Bots that make network calls should plumb signal into their HTTP client (fetch(url, { signal })) so a superseded request is cancelled instead of left in flight.

Determinism and reproducibility

forkRng(base, botName, playerID, turn)

The runner does this for you and passes the result as context.rng. Two bots on the same snapshot get different but reproducible streams; the same bot on the same snapshot always gets the same stream.

createDeadline(budgetMs, clock?) => DeadlineToken

The runner builds one and hands it to decide. Tests can pass a custom clock ({ now(): number }) for deterministic deadline behaviour.

Hosts (low-level)

You will not normally construct hosts directly — attachLocalBot / attachHostedBot do it for you. They exist for advanced uses (custom transports, headless test harnesses).

BotHost<TGame>

createLocalSessionBus(rawSession) / createLocalSessionHost(busOrSession, playerID)

Build the shared notification bus for a local session, then wrap it as a BotHost. The bus exists because multiple bots on the same session must hear each other’s dispatches; constructing one explicitly lets you mix bots with custom drivers.

createHostedClientHost(client, playerID)

Wrap a HostedClient as a BotHost. Used internally by attachHostedBot.

Game-author hooks

@openturn/bot adds one optional field to GameDefinition (and through defineGame in gamekit):
The engine never reads it. The runtime invokes it to surface candidate moves to the bot. Author it once per game and every bot you write — random, heuristic, search-based — works without per-bot scaffolding.