Have more questions? Join our

The Rust SDK

Every v3 game implements Game and exports it:

use boardweaver_live::{
    Action, AvailableAction, Game, GameConfig, Intent, Kinds, Layout, PieceDef, PieceKind,
    PlayerId, Rng, Score, Scores, SpaceDef, SpaceKind, StartingPlayer, Table,
};
use serde_json::{json, Map, Value};

pub struct MyGame;

impl Game for MyGame {
    fn config(num_players: usize) -> GameConfig { /* ... */ }
    fn kinds() -> Kinds { /* optional, defaults to none */ }
    fn prepare(table: &mut Table, rng: &mut Rng) { /* optional: shuffles, deals */ }
    fn available_actions(table: &Table, player_id: PlayerId) -> Vec<AvailableAction> { /* ... */ }
    fn apply_action(table: &mut Table, player_id: PlayerId, action: &Action, rng: &mut Rng) { /* ... */ }
    fn scores(table: &Table) -> Scores { /* ... */ }
    fn is_game_over(table: &Table, scores: &Scores) -> bool { /* ... */ }
}

boardweaver_live::export_live_game!(MyGame);

The Game trait

Method When it runs What it returns
config(num_players) Once, when a match starts The table: spaces, starting pieces, score labels, metadata, and who starts
kinds() When the client loads Each piece and space kind with its width and height
prepare(table, rng) Once, after seating, before the first move Nothing; changes the table (shuffle, deal)
available_actions(table, player_id) After every move, for each active player Every legal action, with its intent and label
apply_action(table, player_id, action, rng) For each move, after the runner has checked it was offered Nothing; changes the table
scores(table) After every move Each seat's points, keyed by player id
is_game_over(table, scores) After every move Whether the game has ended

The runner only applies an action available_actions offered to that player, and only for a player in activePlayerIds.

Setting up: GameConfig

GameConfig {
    starting_player: StartingPlayer::Random, // or StartingPlayer::All
    score_labels: vec!["Points".to_string()],
    meta_data: Map::new(),
    spaces: vec![(
        "0".to_string(),
        SpaceDef { kind: "cell".to_string(), layout: Layout::Stack, x: 0.0, y: 0.0 },
    )],
    pieces: Vec::new(), // (piece id, PieceDef) pairs that start off the board
}

Layout is Stack, Horizontal or Vertical. Players are seated by the platform in the order they joined, and seat colours are assigned for you.

Pieces and kinds

PieceDef::with_image("x-token", "XPiece") is a piece kind showing one uploaded image. For several faces, build it directly:

PieceDef {
    kind: "disc".to_string(),
    order: 1.0,
    orientations: vec![
        Orientation { image_src: Some("DarkSide".to_string()), width: None, height: None },
        Orientation { image_src: Some("LightSide".to_string()), width: None, height: None },
    ],
}

kinds() declares sizes:

fn kinds() -> Kinds {
    Kinds {
        pieces: vec![PieceKind { kind: "disc".to_string(), width: 50.0, height: 50.0 }],
        spaces: vec![SpaceKind { kind: "cell".to_string(), width: 56.0, height: 56.0 }],
    }
}

Reading the state

table.state() is the whole state, read-only:

  • active_player_ids: Vec<PlayerId>
  • players: Vec<Player>, each with player_id, color, username
  • pieces: Vec<Piece>, each with piece_id, kind, orientations and state (space_id, current_orientation_index, order, is_selected)
  • spaces: Vec<Space>, each with space_id, kind, x, y
  • meta_data: Map<String, Value>

Helpers on the state: spaces_of_kind(kind), space(id) (panics if missing), pieces_in(space_id), and seat_of(player_id) (the seat index, from 0).

For a large board, read it into your own arrays once per call rather than scanning pieces for every cell.

Changing the state

Every change goes through Table, which records it:

Method Effect
add_piece(space_id, piece_id, &def) -> usize Places a new piece and returns its index in pieces. Panics on a duplicate id or an unknown space.
remove_piece(index) Removes a piece; later pieces move down by one.
move_piece(index, space_id) Moves a piece to another space.
set_orientation(index, orientation) Turns a piece to another face.
set_active_players(players) Sets whose turn it is.
set_meta(key, value) Sets one key of meta_data.

Piece indexes are positions in table.state().pieces. If you need a change Table does not offer, file a ticket with file_ticket rather than working around it.

Actions

pub enum Action {
    SpaceClick { space_id: String },
    PieceClick { piece_id: String },
    ButtonClick { button: Button }, // Button { id, label, location, disabled }
}

available_actions returns AvailableAction { action, intent, label }. Intent::Confirm finishes the turn; Intent::Choice is a step towards a move that is not finished yet.

Randomness: Rng

rng.below(n) is a number from 0 up to but not including n; rng.roll(sides) is a die roll from 1 to sides; rng.shuffle(&mut items) shuffles a slice. The server seeds Rng with fresh secret randomness on every move, so a roll cannot be predicted, and a move that uses it is never shown before the server confirms it.

Scores

fn scores(table: &Table) -> Scores {
    table.state().players.iter().map(|player| (
        player.player_id.to_string(),
        Score { private_points: Vec::new(), public_points: vec![0.0] },
    )).collect()
}

public_points lines up with score_labels.

Failures

A panic! in any method fails that call with the panic's message, and the match keeps the state it had before the move. A move that runs too long or uses too much memory fails the same way.