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

Build and check a profile
- Implement the
Botcontract below with a stable ID and localized name and description keys. Done when the profile compiles. - Choose only from the offered actions. Use the supplied random source and simulation for the search you need. Done when the chooser returns a valid offered index or no choice.
- Register the bot as described below and run
validate_code. Done when validation accepts its profile and tests pass. - Add the bot to a test lobby and play a full game. Done when the bot handles its turns and the ending correctly.
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]);
with its text in /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), Standard or Hard, or None. |
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.
A bot's name and description are plain text: their messages have no placeholder, plural, select or tag. In every locale your game has, a name is 1 to 40 characters and a description 1 to 280. validate_code reports a key your 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 translation changes it with the next commit, and a commit made before bots had keys keeps the text it has.
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, with every command input expanded into one offer for each value. 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. Size a search to it. In a match it is Boardweaver's budget for one bot call. Inplay_botsit is a part of one test's fuel (Testing a bot).
A bot keeps nothing between calls and has no clock. When choose answers None, an index out of range, panics or runs past its budget, the platform plays a random offer for it.
Looking ahead
turn.simulate::<MyGame>() gives a match loaded from the seat's view. Apply offers to it to see what happens. Each call gives a fresh 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 move.
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 the game runner does. 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
None, an index out of range, or panics, so the platform 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 seed, 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 promises 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 no earlier call was promised, divided by one more than the decisions max_decisions still allows. Decisions of one step each get equal shares. 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 promises that fuel again. Play one game per test with a bot that searches to itsturn.fuel_limit. A bot that ignores it, 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. This test, shortened from the tic-tac-toe seed, 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()
}
);
}
}
A test of one position passes PLAY_BOTS_FUEL_LIMIT as the fuel limit, since it makes one bot call.