Testing a game
Check the game logic, the client's transitions and what players see separately. validate_code builds the game, runs its unit tests and type-checks the client. A play session and browser checks cover interactions and presentation.
validate_code
Run it after every change, before commit. It:
- builds the game logic (
/src/*.rs) and answers the compiler's errors and warnings; - runs the game logic's unit tests, below;
- builds the client (
/src/frontend.tsxand the files it imports) as a commit does, and answers each error with its file, line and column; - type-checks the client when the client and the game logic build, also when a unit test fails, below;
- checks the catalogs and each bot's name and description (Translations, Bots);
- compiles the rules pages (
/src/rules/*.bwss) and the marketing page (/src/marketing/*.bwss).
It answers { valid, errors, warnings, tests }. tests counts the unit tests that passed, failed and were ignored, and is absent when none ran. A game without /src/frontend.tsx is not valid, because its matches cannot load.
Unit tests
validate_code runs each #[test] function in the game logic every time. commit and start_game run them when the Rust files, data files or default catalog changed since the last commit. A failed test is an error: validate_code lists it, and commit refuses with BUILD_FAILED. The tests run only when the game logic builds without errors.
Write tests in a #[cfg(test)] module. Drive a match with Match::new and apply, as a player would. This test from the tic-tac-toe test game plays a game to its end, then checks that Boardweaver refuses a step after it:
#[cfg(test)]
mod tests {
use super::*;
use boardweaver_sdk::{NewMatchInput, Refusal, Seed};
fn seats() -> Vec<PlayerId> {
vec![1, 2]
}
#[test]
fn a_finished_game_offers_nothing_and_refuses_every_move() {
let (mut held, _) = Match::<TicTacToe>::new(NewMatchInput {
players: seats(),
bots: BTreeMap::new(),
seed: Seed([7; 32]),
record_patches: true,
});
let mut last = None;
for cell in [0, 3, 1, 4, 2] {
let player_id = held.state().active_player_ids[0];
last = Some(
held.apply(ApplyInput {
player_id,
action: Action::SpaceClick {
space_id: cell.to_string(),
},
seed: Some(Seed([0; 32])),
})
.expect("an offered move"),
);
}
let finished = last.expect("five moves").evaluation;
assert!(finished.is_game_over);
assert!(finished.offers.is_empty());
let loser = held.state().active_player_ids[0];
let before = held.state().clone();
assert_eq!(
held.apply(ApplyInput {
player_id: loser,
action: Action::SpaceClick {
space_id: "8".to_string(),
},
seed: Some(Seed([0; 32])),
}),
Err(Refusal::NotOffered)
);
assert_eq!(held.state(), &before);
}
}
To check that the game's bots finish a game, see Testing a bot.
- A test that panics fails. The error points at the panic's line and quotes its message.
- Assert on the
Errthe game answers. The build refuses#[should_panic], because a game panics by aborting. #[ignore]skips a test. It counts asignored.- A test sees no files and no environment. Its clock starts at zero, and every source of randomness but a
Seedgives zeros. Draw randomness from aSeed, as the game logic does.
The number of tests, and the fuel, memory and time they may use, have limits: see Builds, limits and errors.
Driving a match in a test
A test drives the game through Match, as Boardweaver does:
| Call | Answers |
|---|---|
Match::<G>::new(NewMatchInput { players, bots, seed, record_patches }) |
(Match<G>, NewMatchOutput). NewMatchOutput has state, evaluation, used_randomness and lines. |
Match::<G>::load(LoadMatchInput { state, record_patches }) |
(Match<G>, Evaluation): a match at a state the test built. |
held.apply(ApplyInput { player_id, action, seed }) |
Result<ApplyOutput, Refusal>. ApplyOutput has patches, movements, evaluation, used_randomness, undoable and lines. |
held.state() |
&State, the true state. |
An Evaluation has these fields:
| Field | Type | Holds |
|---|---|---|
offers |
BTreeMap<PlayerId, Vec<Offer>> |
each active seat's offers, by player id |
scores |
Scores |
each seat's scores |
is_game_over |
bool |
whether the match has ended |
Match has no method that reads its offers. Keep the evaluation of the last new, load or apply, and read evaluation.offers[&player_id].
apply refuses a step with one of these:
Refusal |
Why |
|---|---|
NotOffered |
the action is not one of the seat's offers |
InputDoesNotFit |
the command's value does not fit its offer's input |
Game(reason) |
the game logic refused it, for reason, the Message it returned |
NeedsServer |
seed is None, and the step draws randomness or reveals |
A test cannot make an Rng. Pass a Seed to new and to each apply, and the SDK draws the game's randomness from it. The same seeds give the same match on every run.
To check what a seat sees, call view(held.state(), Some(player_id)), or view(held.state(), None) for a spectator.
Type errors
validate_code type-checks /src/frontend.tsx and every .ts and .tsx file it imports, directly or through other files, as strict TypeScript. It does not check a file that the client never imports, because the client build leaves it out. It checks them against boardweaver/react, the client types in boardweaver/game (Client types) and the client libraries. Each problem is one error, Type error: <file>:<line>:<column>: <message>.
useBoard()returnsnulluntil the match arrives. Check fornullbefore you read the board.fetch,window,documentandlocalStoragedo not exist. The frame blocks them, so a client that uses them fails in a match.
The type check runs whenever the game logic builds, also when a unit test fails. It uses the client types of that build. When the game logic does not build, boardweaver/game holds the client types of the last commit, and the type check does not run. Fix the game logic errors first.
A commit does not type-check, so a client with type errors still commits. Fix every type error before you commit: each one is a mistake a player meets in a match.
validate_code runs no game code except the unit tests. It finds a wrong intent, a missing reveal or a panic only where a test reaches it. Read the code against Offers, intents and decisions and Hidden information, and write a test for each mistake you fix.
How to read its errors: Builds, limits and errors.
Playing a session
A session runs the worktree's game logic in Boardweaver's game runner. It writes no match, changes nothing in the game, and expires 30 minutes after its last change. Test a game with MCP gives the order of the calls.
| Tool | What it does |
|---|---|
start_game |
Builds the game logic and the client as commit does. Seats players 1 to numPlayers, or starts at a state you pass. Answers each active player's offers and a stateRef. |
apply_action |
Takes one step: a player id and one offer's action. A step that ends the decision commits it, and the answer holds its log lines, each with its text in the default locale. Any other step leaves the decision open, and the answer lists the offers after it. |
inspect_state |
Reads the true state and the log lines of the last committed decision. Pass viewer as a player id or "spectator" to read that view. |
set_state |
Writes a whole state, or one path in it such as metaData.round, to test a position without playing to it. It drops an open decision. |
render_game |
Shows the client as one seated player sees the board: format: "html" for its markup and CSS, format: "image" for a screenshot. A seat with an open decision sees the board after its steps. Pass locale to show one of the game's locales, or en-XA (Checking a locale). |
The session keeps the build it started with. Start a new session after you edit the game logic, the client or a catalog. When the client does not build, start_game's warnings says why, and render_game answers RENDER_UNSUPPORTED.
Use a session to check what a build cannot:
- Each state offers what the rules allow.
- Each decision ends where it should (Offers, intents and decisions).
- Each seat's view hides what it must (Hidden information).
A render shows one board at rest: no steps play, and the client takes no action. Check moves, drafts and Undo in the studio preview or in a match on the beta channel.
Checkpoints

A checkpoint is a named position that the studio preview can restore. To save a session's position, call save_checkpoint with its stateRef and a name, only when the user asks for one. Finish any open decision first. The checkpoint stays after the session expires.
In the studio's Play tab, open Checkpoints and choose Save Checkpoint to save the preview's position. Select a saved name to restore it. Editors can restore or delete every checkpoint, also one saved through MCP.
- A checkpoint stores the last committed state, with hidden information, player ids and bot assignments. It does not store local drafts or earlier history.
- Restoring evaluates the saved state with the current game logic. It starts new history at that position, so earlier steps cannot be undone.
- Steps after a restore survive a reload and a replay after a Rust build, unless the preview settings ask for a new match. Restart starts from
setup. - When the changed game logic cannot load a saved state, the studio reports it in Preview notices. Fix the game logic or restart.
Playing on the beta channel
Each commit becomes the game's beta channel. Ask a person to play a match there to test the game:
- Play once at each seat count the game allows.
- Play to the end, and check the winner and the scores.
- Try undo, a refused step, and a reload in the middle of a decision.
- For hidden information, watch from two seats and as a spectator: each must see only what it may.
When a step fails in a match, the game's studio lists the match error. It shows the code, such as GAME_FAILED or EXECUTION_TIMEOUT, and the panic message (Builds, limits and errors).

Client checks
validate_code runs the Rust unit tests only. It runs no JavaScript test and no browser test. Keep the client's tests and fixtures with the game's source. Write down their dependencies and the command that runs them. Run them before every commit.
Use this transition table as the list of cases to test:
| Dimension | Cases to cover when the game supports them |
|---|---|
| Move | Draw, select, change selection, place, move, capture, transfer between players, reveal and finish |
| Viewer | Acting seat, each other seat and spectator |
| Direction | Live play, forward replay, draft undo, committed undo and backward replay |
| Input | Click, drag and keyboard activation |
| Presentation | Narrow, wide and short containers; longer translated text; light and dark modes; reduced motion |
| Interruption | Another step during a flight, repeated Back and Forward, repeated acknowledgement, resize, hidden tab, speed change, reload and timeline jump |
For each transition the game supports:
- Assert its source, its destination and how it looks. Assert that hidden faces stay hidden.
- Assert
snapwhere no correspondence exists. Assert that an unsupported or unclear transition shows the destination directly. A missing animation must never hide the destination. - Check intermediate selections as well as completed placements. A combined row can skip a missing intermediate anchor.
- Check that a repeated visit gets a new transition
id. Check that a completion from a replaced run changes nothing. - Batch two updates before a render: the skipped source must snap.
- Check early completion and overlapping holds. Move anchors without resizing them, both at rest and during a flight.
Keep pure transition tests apart from browser tests. A pure test checks the plan. A browser test measures the motion and layout on screen.
- Use the real client core for timeline tests. A mocked hook harness checks how the game draws, not replay traversal or what each viewer may see.
- Build integration cases from real game actions and the movements each viewer receives.
- Test continuous playback apart from stepping through log rows.
In a browser test:
- Compare positions before, during and after an animation.
- Check that the controls and the board keep their reserved size.
- Check dialog focus, Escape, focus return, and a way to play without dragging.
- Check that reduced motion shows the destination at once.
Record the cases you ran. A screenshot of a board at rest does not prove that an animation works.