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);
| 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.
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
}
| 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.
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(),
}
}
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_statespaces: Vec<Space>, each with space_id, kind, owner, arrangement, public_state, private_state, and its piecesmeta_data: Map<String, Value>, which every viewer receiveslog: 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).
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.
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:
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.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.
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.
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 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.
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.
apply_action does not run again.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.
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 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.