Game logic

v3 · Preview

Framework v3 is an opt-in preview available to everyone. New games use the stable framework by default.

Write a bot

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

The host can see bot seats alongside human players before starting the match.
The host can see bot seats alongside human players before starting the match. Example data from the local development UI. Select the image to enlarge.

Build and check a profile

  1. 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.
  2. Write choose so it answers an index into turn.offered (choose). Size any search to turn.fuel_limit (Looking ahead).
  3. List the bot in export_game! (The game functions).
  4. Write a #[test] that plays whole games with play_bots (Testing a bot).
  5. Run validate_code until it reports no errors and no failed test.
  6. 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_id is the bot's seat.
  • turn.seen is the seat's view, with the bot's earlier steps of this decision applied. A bot sees only what its seat may see.
  • turn.offered holds only Choice and Confirm offers. 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 from min to max. Piece indexes are positions in turn.seen.
  • turn.step counts the steps the bot has already taken in this decision.
  • turn.fuel_limit is the fuel this call may use. In a match, it is Boardweaver's budget for one bot call. In play_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

Preview settings assign humans or bots to seats and control what happens after a build.
Preview settings assign humans or bots to seats and control what happens after a build. Example data from the local development UI. Select the image to enlarge.

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 None or 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 Choice or Confirm, 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 Choice or Confirm;
  • the match has not ended after max_decisions decisions.

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_decisions close to the game's real length. A larger one gives each call less fuel.
  • Each play_bots call has the full PLAY_BOTS_FUEL_LIMIT again. Play one game per test with a bot that searches up to its turn.fuel_limit. A bot that ignores the limit, such as the perfect bot 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()
            }
        );
    }
}

Need a hand with this?

Join our Discord