Hidden information
Boardweaver hides from each seat what it may not see. Put each secret where the rules below hide it. The game logic never filters a view itself.
Who sees what
| Part | Seen by |
|---|---|
a piece's public_kind and public_state |
everyone |
a piece's private_kind and private_state |
the piece's owner; if it has none, its space's owner; if neither, nobody |
a space's public_state |
everyone |
a space's private_state |
the space's owner |
a player's public_state |
everyone |
a player's private_state |
that player |
meta_data |
everyone |
A spectator sees only public parts. Keep a secret that nobody may see yet, such as the order of a deck, as a private part with no owner.
To show a card only to its holder, put it face down in a space the holder owns:
table.add_space(
Space::new(format!("hand-{player}"), "hand")
.collection()
.owned_by(player)
.with_pieces(vec![Piece::new("back").hiding("queen-of-hearts")]),
);
To turn a card face up for everyone, set its public kind: table.set_public_kind(space_id, index, &face).
Collections hide order
A space is Ordered by default: every viewer sees every piece in its true position, without the private parts that viewer may not see.
A space made with .collection() shows each viewer the pieces it can see first, in order. The hidden pieces follow, grouped by their public part. A viewer cannot follow a hidden piece through a collection. So a hand of face-down cards gives nothing away when its holder sorts or plays it.
A click on a hidden piece
A seat that clicks a hidden piece in a collection cannot tell that piece from the others of the same public part. Boardweaver picks one piece of that group, which the seat cannot predict. The game logic receives the index of the picked piece, never the clicked position.
Reveals
A step is a reveal when its result depends on something the seat cannot see: drawing the top card of a deck, taking a card from another hand, looking at a hidden tile. Mark its offer with .revealing(). Hexbloom's draw is a reveal:
Offer::new(Action::command(DRAW), Intent::Confirm)
.label(msg!("offer.draw"))
.revealing()
A reveal ends the seat's decision (Offers, intents and decisions). The board shows its result only after Boardweaver applies it. Without the mark, the client can show the player a result that then changes.
Secret picks made at the same time
In some games, every seat picks in secret: a card and the place it goes, a bid, a planned move. The picks resolve together once every seat has confirmed its decision. Keep each pick on the board where only its seat sees it. Then the player sees the pick before the reveal.
The draft runs on the seat's device, and Boardweaver receives nothing until the decision ends (Offers, intents and decisions). A decision that ends becomes part of the board every seat sees. So hide each confirmed pick from the other seats until the reveal. Give each seat a plan space it owns:
const SLOTS: [&str; 3] = ["slot-0", "slot-1", "slot-2"];
const CONFIRM: &str = "confirm";
fn hand_id(player: PlayerId) -> String {
format!("hand-{player}")
}
fn plan_id(player: PlayerId) -> String {
format!("plan-{player}")
}
for &player in players {
table.add_space(Space::new(plan_id(player), "plan").collection().owned_by(player));
}
Make a pick in two steps: a click on a card, then a click on its target. The click on a card only stages it, so the card stays in the hand. While a card is staged, offer only its targets, each as a Choice. The client then shows the card as picked. A second click on the card takes the pick back with Undo (Moving pieces).
The click on a target moves the staged card into the plan, face down. It writes the target in the piece's private state, which only the plan's owner sees. The other seats see only a card back in the plan. Both steps are Choice steps, so the board shows each one at once.
Offer the Confirm only when nothing is staged and the plan holds a card. The last seat's Confirm reads the other seats' hidden plans, so mark it .revealing():
#[derive(Deserialize, Default)]
#[serde(crate = "boardweaver_sdk::serde", default)]
struct Pick {
staged: Option<usize>,
}
fn offers(table: &Table, player_id: PlayerId) -> Vec<Offer> {
let Pick { staged } = table.state().read(StateOf::PlayerPrivate(player_id));
if staged.is_some() {
return SLOTS
.map(|slot| Offer::new(Action::SpaceClick { space_id: slot.into() }, Intent::Choice).label(msg!("offer.plan")))
.to_vec();
}
let hand = hand_id(player_id);
let mut offers: Vec<Offer> = (0..table.state().space(&hand).pieces.len())
.map(|index| Offer::new(Action::PieceClick { space_id: hand.clone(), index }, Intent::Choice).label(msg!("offer.stage")))
.collect();
if !table.state().space(&plan_id(player_id)).pieces.is_empty() {
let confirm = Offer::new(Action::command(CONFIRM), Intent::Confirm).label(msg!("offer.confirm"));
let last = table.state().active_player_ids == [player_id];
offers.push(if last { confirm.revealing() } else { confirm });
}
offers
}
Each Confirm takes its seat out of the active players. The last Confirm resolves every plan:
match action {
Action::PieceClick { index, .. } => {
table.set(StateOf::PlayerPrivate(player_id), "staged", json!(index));
}
Action::SpaceClick { space_id } => {
let hand = hand_id(player_id);
let plan = plan_id(player_id);
let Pick { staged } = table.state().read(StateOf::PlayerPrivate(player_id));
let staged = staged.expect("a slot is offered only while a card is staged");
let face = table.state().piece(&hand, staged).public_kind.clone();
let at = table.move_piece(&hand, staged, &plan);
table.set_private_kind(&plan, at, Some(&face));
table.set_public_kind(&plan, at, "back");
table.set(StateOf::PiecePrivate(&plan, at), "target", json!(space_id));
table.set(StateOf::PlayerPrivate(player_id), "staged", None);
}
Action::Command { .. } => {
let waiting: Vec<PlayerId> =
table.state().active_player_ids.iter().copied().filter(|&player| player != player_id).collect();
if waiting.is_empty() {
resolve_plans(table);
} else {
table.set_active_players(waiting);
}
}
}
This hand is face up, so the other seats see which card leaves it. When the hand is hidden, its cards are already face down. Then leave out the face line, set_private_kind and set_public_kind.
When the player keeps every card, add a new piece to the plan instead of moving the card. Replace move_piece, set_private_kind and set_public_kind with let at = table.add_piece(&plan, Piece::new("back").hiding(face));. resolve_plans stays the same.
To resolve the plans, turn each card face up, then move it to its target. Boardweaver records the move, and the client animates it from the plan to its place:
#[derive(Deserialize, Default)]
#[serde(crate = "boardweaver_sdk::serde", default)]
struct Planned {
target: String,
}
fn resolve_plans(table: &mut Table) {
let players: Vec<PlayerId> = table.state().players.iter().map(|player| player.player_id).collect();
for &player in &players {
let plan = plan_id(player);
while let Some(face) = table.state().space(&plan).pieces.first().and_then(|piece| piece.private_kind.clone()) {
let Planned { target } = table.state().read(StateOf::PiecePrivate(&plan, 0));
table.set(StateOf::PiecePrivate(&plan, 0), "target", None);
table.set_private_kind(&plan, 0, None);
table.set_public_kind(&plan, 0, &face);
table.move_piece(&plan, 0, &target);
}
}
let holding = players
.into_iter()
.filter(|&player| !table.state().space(&hand_id(player)).pieces.is_empty())
.collect();
table.set_active_players(holding);
}
In the client, draw the seat's own planned pieces at their targets. Give them a look that says they are not resolved yet. Wrap each one in a Movable with the plan's spaceId and the piece's index (Moving pieces). The target step then flies the card from the hand to its target, and the reveal flies it from there. A spectator has no plan. Put one PlannedCards inside the MoveTarget of each target:
function PlannedCards({ slotId }: { slotId: string }) {
const self = useSelf();
const board = useBoard();
if (!self) return null;
const planId = `plan-${self.playerId}`;
const plan = board?.spaces.find((space) => space.spaceId === planId);
return (plan?.pieces ?? []).map(
(piece, index) =>
piece.privateState?.target === slotId && (
<Movable key={index} spaceId={planId} index={index}>
<Card kind={piece.privateKind} pending />
</Movable>
),
);
}
Draw another seat's plan as face-down cards in that seat's area, inside a MoveTarget with that plan's spaceId.
The game logic also runs on a view
offers and apply_action also receive the seat's own view, where every private part the seat may not see is missing. Write them so they work on a view:
- Read private parts with
Optionhandling, notexpect. On another seat's hidden piece,private_kindisNone. - Read private maps with types whose fields all have defaults. A hidden map reads as
{}. - Mark every step whose result needs a missing part as a reveal. Boardweaver then applies it with every part present.
Log lines and labels
- Every viewer reads every log line, so write only public facts in a line (Changing the state).
- Only the seat that receives an offer sees its label, so a label may name that seat's own hidden cards.