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);
| 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.
GameConfigGameConfig {
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.
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 }],
}
}
table.state() is the whole state, read-only:
active_player_ids: Vec<PlayerId>players: Vec<Player>, each with player_id, color, usernamepieces: 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, ymeta_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.
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.
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.
Rngrng.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.
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.
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.