Have more questions? Join our

The Rust SDK

Every v3 game implements Game and exports it:

use boardweaver_live::{
    Action, AvailableAction, Game, GameConfig, Intent, LogEntry, Piece, PlayerId, Rng, Score,
    Scores, Space, StartingPlayer, Table,
};
use serde_json::{json, Map, Value};

pub struct MyGame;

impl Game for MyGame {
    fn config(players: &[PlayerId]) -> GameConfig { /* ... */ }
    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(players) Once, when a match starts, with the seated player ids in seat order The table: its spaces and the pieces in them, score labels, metadata, and who starts
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.

Pieces live in spaces, and have no ids

A piece sits in its space's pieces, and its position there is its name: the platform addresses it as { spaceId, index }, and so do you. There is no piece id and no order field to manage, because pieces is the order.

Indexes move. An index you read is good until the next change to that space, so read it again rather than storing it between calls.

for (index, card) in table.state().space("hand-7").pieces.iter().enumerate() {
    // `card.public_kind` is what everyone sees; `index` is how you name it
}

What a piece is

Field Who receives it What it is
public_kind everyone What the piece looks like from outside: a card back, or the piece type in an open game
public_state everyone Visible details, such as tapped or damaged
private_kind its viewers What the piece really is. Optional
private_state its viewers Hidden details
owner everyone Optional. A player who sees the private part wherever the piece sits

A piece's viewers are its owner if it has one, otherwise the owner of the space it is in, otherwise nobody. That one rule is all of hidden information.

Piece::new("back")                    // a face-down card, back showing
    .hiding("ace-of-spades")          // what it really is
    .with_public("bent", json!(true)) // everyone can see it is bent
    .with_private("noted", json!(1))  // only its viewers can
    .owned_by(player_id)              // optional: its own viewer

Read them back with piece.public("bent") and piece.private("noted"), which give None when the viewer running the rules may not see it.

Spaces

Space::new("hand-7", "hand")   // an id and a kind
    .collection()              // a hand, deck or bag: see Hidden information
    .owned_by(player_id)       // its owner sees into it
    .with_pieces(cards)        // the pieces it starts with

config is handed the seated player ids, so a space can be owned from the first move:

fn config(players: &[PlayerId]) -> GameConfig {
    GameConfig {
        starting_player: StartingPlayer::Random, // or StartingPlayer::All
        score_labels: vec!["Points".to_string()],
        meta_data: Map::new(),
        spaces: players
            .iter()
            .map(|&player| {
                Space::new(format!("hand-{player}"), "hand")
                    .collection()
                    .owned_by(player)
            })
            .chain([Space::new("table", "table")])
            .collect(),
    }
}

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, public_state, private_state
  • spaces: Vec<Space>, each with space_id, kind, owner, arrangement, public_state, private_state, and its pieces
  • meta_data: Map<String, Value>, which every viewer receives
  • log: Vec<LogEntry>

Helpers: space(id) and space_index(id) (both panic when there is none), spaces_of_kind(kind), piece(space_id, index), and seat_of(player_id).

Changing the state

Every change goes through Table, which records it as a patch:

Method Effect
add_piece(space_id, piece) -> usize Puts a piece at the end of a space, and gives back its index
insert_piece(space_id, index, piece) Puts it at a position; later pieces move up
remove_piece(space_id, index) -> Piece Takes it off the table
move_piece(from, index, to) -> usize Moves it to the end of another space
move_piece_to(from, index, to, to_index) Moves it to a position
shuffle(space_id, rng) Shuffles a space
set_public_kind(space_id, index, kind) Changes what a piece looks like, which is how a card is turned face up
set_private_kind(space_id, index, kind) Changes, or with None removes, what it hides
set_piece_owner(space_id, index, owner) Gives its private part to a player wherever it sits
set_piece_public_state(space_id, index, key, value) One key, visible to everyone
set_piece_private_state(space_id, index, key, value) One key, for its viewers
set_space_owner(space_id, owner) Gives a space to a player, or back to the table
set_space_public_state(space_id, key, value), set_space_private_state(...) One key of a space
set_player_public_state(player_id, key, value), set_player_private_state(...) One key of a player
set_active_players(players) Sets whose turn it is
set_meta(key, value) One key of meta_data. Every viewer receives it
log(entry) Adds a line to the game log
allow_undo() Lets the player take this move back

If you need a change Table does not offer, file a ticket with file_ticket rather than working around it.

Hidden information

Each player is served their own view of the state, with everything they may not see removed, and the rules run in their browser on that same view. Anything a player must not learn belongs in a private part, never in meta_data or public_state, which everyone receives.

A space says how it shows the pieces a viewer cannot see:

  • Ordered (the default): every piece, in order, with hidden ones showing only their public part. A board, a face-up discard, a face-down row where position matters.
  • Collection (Space::new(..).collection()): the pieces a viewer can see, then the rest grouped by their public part. A hand, a deck, a bag. There is no order to follow, so an opponent cannot track a card through a shuffle or watch where a drawn card lands.
What you want How to build it
A hand only its holder sees into Collection, owned_by(holder), cards Piece::new("back").hiding(face)
Two visibly different backs The same, with two public_kinds. Everyone sees how many of each
A deck or bag nobody sees into Collection, no owner
Your pieces on a shared board (Stratego) Ordered squares with no owner, pieces owned_by(their player)
Face-down cards whose position matters (Memory) Ordered, no owner; turn one face up with set_public_kind
Showing a card to everyone set_public_kind to what it is
Letting one player peek Write what they saw into their own set_player_private_state

Two rules to follow yourself:

  1. Shuffle an Ordered space that hides pieces when its contents change in a way an observer could follow, such as a card going into a face-down row. A Collection needs no shuffling: it has no visible order.
  2. Never put a secret in meta_data, public_state or a public kind. That is the one leak the platform cannot catch for you.

When the rules run in a player's browser, other players' private parts are simply absent, so read them with private(key) and handle None. Legal actions and scores that depend on a secret the viewer does not hold are only right on the server, whose answer then corrects the browser.

Actions

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

available_actions returns AvailableAction { action, intent, label }. A piece is named by where it is in the state you are handed: the platform has already worked out which piece the player meant, including when they clicked one of a group they cannot tell apart, from which it picks at random.

intent is what the action does to the decision in progress, and it is what a search or a training loop keys off:

Intent Meaning
Intent::Choice Advances the decision without ending it: selecting, staging, picking a target
Intent::Confirm Commits it and hands play on
Intent::Cancel Abandons the decision, back to before its first choice
Intent::Undo The platform's own, for taking back a committed move. A game never offers it

label is for logs and bots, not UI copy.

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 it records the seed so a match replays exactly. A move that draws from Rng is never predicted in the browser and can never be taken back.

Undo

table.allow_undo() in apply_action lets the player take the move back, as long as nobody else has acted since. Undoable moves in a row are taken back one at a time, newest first, and the first move that did not allow it stops them, so allow it on the steps of a turn and not on the move that ends it.

  • A move that drew randomness can never be taken back, whatever it allows.
  • Do not allow undo on a move that reveals something, such as turning a card face up: the player would keep what they learned.
  • Undo restores the state exactly, log included. apply_action does not run again.

Game log

table.log(entry) adds a line, which clients read with useGameLog():

table.log(
    LogEntry::new(format!("{name} drew 2 cards"))
        .for_player(player_id, "You drew 2 aces"),
);

Every viewer reads text, except players given their own line with for_player, so a line for one player can say what the others must not see. The state keeps the most recent 500 entries; the match's history keeps them all.

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 ends the move: the platform reports the panic's message, and the match stays as it was, so a failed move is a refusal rather than a corrupted game. Anything the game printed goes with it. A move also fails if it runs past its time or memory budget.