Write a bot
A game may ship bots: computer opponents written in Rust beside the game logic.

Build and check a profile
- Write a type that implements
Bot(A bot). Give it a profile that follows The profile, and add its name and description to the default catalog. - Write
chooseso it answers an index intoturn.offered(choose). Size any search toturn.fuel_limit(Looking ahead). - List the bot in
export_game!(The game functions). - Write a
#[test]that plays whole games withplay_bots(Testing a bot). - Run
validate_codeuntil it reports no errors and no failed test. - Ask the user to play against the bot in the studio preview (Playing against a bot in the studio).
A bot
use boardweaver_sdk::{ApplyInput, Bot, BotTurn, Difficulty, Rng};
pub struct Greedy;
impl Bot for Greedy {
const ID: &'static str = "greedy";
const NAME: &'static str = "bot.greedy.name";
const DESCRIPTION: &'static str = "bot.greedy.description";
const DIFFICULTY: Option<Difficulty> = Some(Difficulty::Easy);
fn choose(turn: &BotTurn, _rng: &mut Rng) -> Option<usize> {
turn.offered
.iter()
.enumerate()
.filter_map(|(index, offer)| {
let mut simulated = turn.simulate::<MyGame>();
let output = simulated
.apply(ApplyInput {
player_id: turn.player_id,
action: offer.action.clone(),
seed: None,
})
.ok()?;
let points = output.evaluation.scores[&turn.player_id].public_points[0];
Some((index, points))
})
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(index, _)| index)
}
}
boardweaver_sdk::export_game!(MyGame, bots: [Greedy]);
Its text goes in the default catalog, such as /src/i18n/en.json:
{
"bot.greedy.description": "Takes whatever scores most right now.",
"bot.greedy.name": "Greedy"
}
The profile
| Constant | Rule |
|---|---|
ID |
1 to 32 characters of a-z, 0-9 and -. Never change it: seats and history store it. |
NAME |
The key of the bot's name in your catalogs (Catalogs), 1 to 128 bytes. The lobby and the bot's seat show its message in the player's language. |
DESCRIPTION |
The key of the bot's description in your catalogs, 1 to 128 bytes. |
DIFFICULTY |
Some(Difficulty::Easy), Some(Difficulty::Standard), Some(Difficulty::Hard) or None, the default. |
A game ships at most 16 bots, each with its own ID. export_game! checks the list when the game compiles, so a bad profile is a build error.
Write a bot's name and description as plain text: no placeholder, plural, select or tag. In every locale of your game, a name is 1 to 40 characters and a description is 1 to 280. validate_code reports a key that the default catalog lacks, or a message that breaks these rules, and commit refuses it with BUILD_FAILED. A commit stores each bot's text in every locale, so a change to a translation shows with the next commit.
Write &'static str. The build refuses the word static on its own, but not the 'static lifetime.
choose
choose answers the index of one offer in turn.offered.
turn.player_idis the bot's seat.turn.seenis the seat's view, with the bot's earlier steps of this decision applied. A bot sees only what its seat may see.turn.offeredholds onlyChoiceandConfirmoffers. Each command input becomes one offer per value. A text input gives one offer per suggestion. An integer input gives at most 1,000 values, spread evenly frommintomax. Piece indexes are positions inturn.seen.turn.stepcounts the steps the bot has already taken in this decision.turn.fuel_limitis the fuel this call may use. In a match, it is Boardweaver's budget for one bot call. Inplay_bots, it is a share of one test's fuel (Testing a bot).
A bot keeps nothing between calls and has no clock. When choose answers None or an index out of range, panics, or runs past its budget, Boardweaver plays a random offer for it.
Looking ahead
turn.simulate::<MyGame>() returns a match loaded from the seat's view. Apply offers to it to see what happens. Each call returns a new match, and apply changes the match it is called on.
A simulated step that draws randomness or reveals needs a seed: pass seed: Some(rng.seed()). That seed is the bot's own, and tells it nothing about the real step.
Playing against a bot in the studio

Open Settings in the studio's game preview. Choose Number of players, then select each seat circle and choose Human or a bot from its menu. Choose Save settings to apply them together.
- The seat count includes you and the bots. A two-player game against one bot needs two seats.
- The bots come from the latest successful Rust build of the worktree, so you can test them before you commit.
- A bot plays by itself when its seat is active and has an offer. You can still switch to any seat or to the spectator's view.
- Restart keeps the bot choices. A reload, or a replay after a Rust build, keeps the bots' recorded steps. Changing the players starts a new match.
- An undo of a bot's decision can reopen earlier steps in that seat's draft. The bots then wait until you finish that decision or undo its remaining steps.
The preview runs bots in your browser. It stops a bot's decision after 10 seconds, and pauses the bots after a timeout or a failure. Read Preview notices, then fix the game and save, or restart. The browser does not count fuel, so check how long a search takes in a real match too.
Testing a bot
boardweaver_sdk::checks::play_bots::<MyGame>(seats, seed, max_decisions) plays a whole match in a unit test, with a bot in every seat, as Boardweaver does in a match. seats lists each seat's player id and the id of its bot, in seat order. max_decisions is the most decisions the match may take. It answers the final state.
It answers a BotMistake when:
- a bot answers
Noneor an index out of range, or panics, so Boardweaver would play a random offer for it; - a seat names a bot the game does not ship, or two seats name the same player id;
- a decision offers the bot no
ChoiceorConfirm, the game refuses every offer the bot takes, or the decision does not end within 64 steps; - the game is not over and no seat has a
ChoiceorConfirm; - the match has not ended after
max_decisionsdecisions.
The message of a mistake at a step names the decision, the step, the seat and the bot.
Call it in a #[test] for each bot, with a few seeds. This test, shortened from the tic-tac-toe test game, plays four games between two perfect bots:
#[cfg(test)]
mod tests {
use super::*;
use boardweaver_sdk::Seed;
use boardweaver_sdk::checks::play_bots;
#[test]
fn perfect_finishes_every_game() {
for byte in 0..4 {
play_bots::<TicTacToe>(&[(11, "perfect"), (22, "perfect")], Seed([byte; 32]), 9)
.unwrap_or_else(|mistake| panic!("{mistake}"));
}
}
}
play_bots gives its bots at most PLAY_BOTS_FUEL_LIMIT in all, half of one test's fuel. The game logic keeps the other half. Each bot call gets the fuel that no earlier call received, divided by one more than the decisions max_decisions still allows. Decisions of one step each get equal shares, and the share kept back covers decisions of more steps. So a bot that spends its whole turn.fuel_limit on every call still fits one game in one test.
- Set
max_decisionsclose to the game's real length. A larger one gives each call less fuel. - Each
play_botscall has the fullPLAY_BOTS_FUEL_LIMITagain. Play one game per test with a bot that searches up to itsturn.fuel_limit. A bot that ignores the limit, such as theperfectbot above, can play a few games in one test.
The limits are in Builds, limits and errors.
Testing one position
To check what a bot chooses in one position, build its BotTurn with BotTurn::new and call bot_choice with the bot's id. bot_turn_offers and view_evaluation give the offers the bot sees, and view gives its view. Pass PLAY_BOTS_FUEL_LIMIT as the fuel limit, since the test makes one bot call. This test, shortened from the tic-tac-toe test game, plays four cells, then checks that perfect takes the winning cell:
#[cfg(test)]
mod tests {
use super::*;
use boardweaver_sdk::bot::{bot_choice, bot_turn_offers};
use boardweaver_sdk::checks::PLAY_BOTS_FUEL_LIMIT;
use boardweaver_sdk::{LoadMatchInput, NewMatchInput, Seed, view, view_evaluation};
fn perfect_move(state: &State) -> Action {
let (_, evaluation) = Match::<TicTacToe>::load(LoadMatchInput {
state: state.clone(),
record_patches: false,
});
let mover = state.active_player_ids[0];
let offered = bot_turn_offers(&view_evaluation(&evaluation, state, Some(mover)), mover);
let seen = view(state, Some(mover));
let turn = BotTurn::new(mover, &seen, &offered, 0, PLAY_BOTS_FUEL_LIMIT);
let chosen = bot_choice::<TicTacToe>("perfect", &turn, Seed([0; 32]))
.expect("perfect always chooses");
offered[chosen].action.clone()
}
#[test]
fn perfect_takes_the_winning_cell() {
let (mut held, _) = Match::<TicTacToe>::new(NewMatchInput {
players: vec![1, 2],
bots: BTreeMap::new(),
seed: Seed([7; 32]),
record_patches: false,
});
for cell in [0, 3, 1, 4] {
let player_id = held.state().active_player_ids[0];
held.apply(ApplyInput {
player_id,
action: Action::SpaceClick {
space_id: cell.to_string(),
},
seed: Some(Seed([0; 32])),
})
.expect("an offered move");
}
assert_eq!(
perfect_move(held.state()),
Action::SpaceClick {
space_id: "2".to_string()
}
);
}
}