Skip to main content
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:
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

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

Pattern B — createLocalBotSupervisor (no React)

For non-React drivers, CLIs, or test harnesses:

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:
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:
  1. Local React app:
    Open the page, pick “Bot · Minimax · hard” for seat 1, click Start. The bot plays.
  2. OSS hosted 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.
  3. 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.