Documentation Index
Fetch the complete documentation index at: https://openturn.io/docs/llms.txt
Use this file to discover all available pages before exploring further.
The bot layer lets a developer drop a computer player into a seat. The lobby layer lets the player pick which computer player goes in which seat — random, minimax-easy, minimax-hard — from a typed registry, before the match starts. Five files, four steps, same code path for local and hosted play.
Step 1 — Register your bots
Build a BotRegistry once. Put it next to your game’s bot implementations:
// examples/games/tic-tac-toe/bots/src/index.ts
import { defineBotRegistry, attachBots } from "@openturn/lobby/registry";
import { ticTacToe } from "@openturn/example-tic-tac-toe-game";
import { randomBot } from "./random";
import { makeMinimaxBot } from "./minimax";
export const ticTacToeBotRegistry = defineBotRegistry([
{ botID: "random", label: "Random", difficulty: "easy", bot: randomBot },
{ botID: "minimax-easy", label: "Minimax · easy", difficulty: "easy", bot: makeMinimaxBot({ depth: 2 }) },
{ botID: "minimax-hard", label: "Minimax · hard", difficulty: "hard", bot: makeMinimaxBot({ depth: 9 }) },
]);
// Re-export the game with the registry attached at game.bots.
// Apps import `ticTacToeWithBots` and the lobby runtime/manifest see the
// registry without the game package depending on the bots package.
export const ticTacToeWithBots = attachBots(ticTacToe, ticTacToeBotRegistry);
defineBotRegistry validates botID uniqueness at definition time. Different difficulties are distinct bot instances — there is no runtime-tunable param.
The attachBots(game, registry) helper sidesteps a circular package dep: the bots package imports the game’s types, so the game can’t import the bots back. Apps import the bot-attached game from the bots package instead.
Step 2 — Render the lobby with bot dropdowns
<LobbyWithBots> is the same as <Lobby> but each seat is a <LobbySeatControl> with an “Assign bot ▾” dropdown for host viewers. The bot catalog comes from lobby.availableBots, which the server populates from the registry — apps don’t have to thread bots into the UI.
Local single-device
// examples/games/tic-tac-toe/app/src/components/LocalLobbyTicTacToe.tsx
import { useState, type ReactNode } from "react";
import { LobbyWithBots, buildLobbyView, useLocalLobbyChannel } from "@openturn/lobby/react";
import { ticTacToeWithBots, ticTacToeBotRegistry } from "@openturn/example-tic-tac-toe-bots";
import { ticTacToe } from "@openturn/example-tic-tac-toe-game";
const HOST_USER_ID = "local-host";
export function LocalLobbyTicTacToe(): ReactNode {
const [phase, setPhase] = useState<"lobby" | "game">("lobby");
const [botMap, setBotMap] = useState<Record<string, string>>({});
const channel = useLocalLobbyChannel({
game: ticTacToeWithBots,
hostUserID: HOST_USER_ID,
hostUserName: "You",
registry: ticTacToeBotRegistry,
onTransitionToGame: ({ assignments }) => {
const next: Record<string, string> = {};
for (const a of assignments) {
if (a.kind === "bot" && a.botID !== null) next[a.playerID] = a.botID;
}
setBotMap(next);
setPhase("game");
},
});
const view = buildLobbyView({
channel,
userID: HOST_USER_ID,
capacityFallback: ticTacToeWithBots.playerIDs.length,
minPlayersFallback: ticTacToeWithBots.minPlayers,
hostUserIDFallback: HOST_USER_ID,
});
if (phase === "lobby") {
return <LobbyWithBots lobby={view} title="Tic-tac-toe · pick your seats" />;
}
return <Board botMap={botMap} />;
}
Open the page, click “Assign bot ▾” on seat 1, pick “Minimax · hard”, click “Start”. The transition fires; botMap is { "1": "minimax-hard" }. The game phase mounts and the bot starts playing.
Hosted multiplayer (dev server + cloud)
The hosted lobby uses the WebSocket-backed useLobbyChannel instead of useLocalLobbyChannel, but the rest is identical:
import { LobbyWithBots, buildLobbyView, useLobbyChannel } from "@openturn/lobby/react";
function HostedLobby({ token, websocketURL, userID }) {
const channel = useLobbyChannel({ websocketURL, token });
const view = buildLobbyView({ channel, userID });
return <LobbyWithBots lobby={view} title="Tic-tac-toe" />;
}
The OSS dev server (openturn dev) and the cloud Durable Object both read game.bots and feed buildKnownBots(registry) into LobbyEnv.knownBots automatically — no app glue. The dropdowns appear as soon as attachBots(game, registry) is set on the deployed game.
Step 3 — Wire bots into the freshly-started session
The transition gives you the seat→bot map. You need to attach Bot<TGame> instances to the corresponding seats so they dispatch moves.
Pattern A — useBotAttachOnTransition (simplest)
For apps that hold a raw LocalGameSession directly:
import { useBotAttachOnTransition } from "@openturn/lobby/react";
import { createLocalSession } from "@openturn/core";
function Game({ channel }) {
const [rawSession] = useState(() =>
createLocalSession(ticTacToe, { match: { players: ticTacToe.playerIDs } }),
);
const facade = useBotAttachOnTransition({
channel,
game: ticTacToe,
registry: ticTacToeBotRegistry,
session: rawSession,
});
// Drive the UI with `facade ?? rawSession`. The facade is non-null
// only when at least one bot seat exists; using it routes human
// dispatches through the runner so bots see them.
const session = facade ?? rawSession;
return <BoardUI session={session} />;
}
Pattern B — createLocalBotSupervisor (no React)
For non-React drivers, CLIs, or test harnesses:
import { createLocalBotSupervisor } from "@openturn/lobby/supervisor";
const supervisor = createLocalBotSupervisor({
session: rawSession,
game: ticTacToe,
registry: ticTacToeBotRegistry,
});
await supervisor.start(
channel.transition.playerAssignments
.filter((a) => a.kind === "bot")
.map((a) => ({ seatIndex: a.seatIndex, playerID: a.playerID, botID: a.botID! })),
);
const session = supervisor.getSession(); // bot-aware facade — drive with this
Inspector-aware bot driver
If you use createOpenturnBindings({ runtime: "local" }) and want bot moves to flow through the same matchStore (so the inspector timeline tracks them alongside human moves), wire bots manually with a small effect keyed on snapshot:
import { useEffect, useRef } from "react";
import { findBot } from "@openturn/lobby/registry";
import { enumerateLegalActions, forkRng, simulate } from "@openturn/bot";
import { ticTacToeBotRegistry } from "./bots";
function useBotDriver({ botMap, snapshot, dispatch, getPlayerView }) {
const inflightTurnRef = useRef<number | null>(null);
useEffect(() => {
if (snapshot.meta.result !== null) {
inflightTurnRef.current = null;
return;
}
const active = snapshot.derived.activePlayers as readonly string[];
const botSeat = active.find((p) => botMap[p] !== undefined);
if (botSeat === undefined) return;
const descriptor = findBot(ticTacToeBotRegistry, botMap[botSeat]!);
if (descriptor === null) return;
const turn = snapshot.position.turn;
if (inflightTurnRef.current === turn) return;
inflightTurnRef.current = turn;
const abort = new AbortController();
let cancelled = false;
void (async () => {
const action = await descriptor.bot.decide({
playerID: botSeat as never,
view: getPlayerView(botSeat) as never,
snapshot: snapshot as never,
legalActions: enumerateLegalActions(ticTacToe, snapshot, getPlayerView(botSeat), botSeat, descriptor.bot),
rng: forkRng(snapshot.meta.rng, descriptor.bot.name, botSeat, snapshot.position.turn),
deadline: { remainingMs: () => 5_000, expired: () => false },
signal: abort.signal,
simulate: (a) => simulate(ticTacToe, snapshot, botSeat, a),
});
if (cancelled) return;
const handler = dispatch[action.event];
if (typeof handler === "function") handler(botSeat, action.payload);
})();
return () => {
cancelled = true;
abort.abort();
};
}, [botMap, snapshot, dispatch, getPlayerView]);
}
This pattern is what the in-tree tic-tac-toe app uses. It’s slightly more code than useBotAttachOnTransition but every dispatch — human and bot — flows through the bindings’ matchStore, so the inspector shows the full timeline.
Step 4 — Verify
Smoke test in three modes:
-
Local React app:
bun --filter @openturn/example-tic-tac-toe-app dev
Open the page, pick “Bot · Minimax · hard” for seat 1, click Start. The bot plays.
-
OSS hosted dev:
bun --filter @openturn/example-tic-tac-toe-multiplayer-app dev
In tab A, pick “Bot · Random” for seat 1. In tab B, take seat 0. Click Start in tab A. Both browsers see the bot play.
-
Cloud: deploy a build with
attachBots(game, registry) set and play a public room. The cloud Durable Object reads the manifest’s availableBots and instantiates the in-DO BotDriver automatically.
A larger worked example: Splendor
examples/games/splendor ships the same pattern at production polish — a 2–4 player hosted game with three difficulty-tiered bots in the lobby. See splendor/bots/src/index.ts for defineBotRegistry declaring random, greedy, and strategic, and splendor/app/ for the <LobbyWithBots> wiring against hosted multiplayer. Run it with bun --filter @openturn/example-splendor-app dev and open the printed URL in 2–4 tabs to see the per-seat dropdowns under load.
Common rejections
seat_has_human — you tried to assignBot to a seat someone is sitting in. Use clearSeat first or pick a different seat.
seat_has_bot — a non-host viewer tried to takeSeat on a bot seat. Bot seats are host-controlled; the host has to clearSeat first.
unknown_bot — the wire botID isn’t in LobbyEnv.knownBots. Usually means the deployed game wasn’t built with attachBots. Rebuild and redeploy.
Limitations
- Different difficulties are distinct bot instances. No runtime-tunable params yet. If you want a “depth slider”, instantiate one descriptor per slider value or extend
BotDescriptor with params?: Record<string, unknown> and pipe it through lobby:assign_bot.
- The local channel auto-seats the host. Single-device play assumes one human; the host takes seat 0 on mount. To skip auto-seat (rare), pass
autoSeatIndex: null to useLocalLobbyChannel.
- No bot-vs-bot starting condition for hosted. The host has to take a seat for
start() to gate properly. If you want a “headless host” who only configures and starts, lower minPlayers and don’t auto-seat.