Have more questions? Join our Discord

Testing a game

A game has two checks. validate_code builds the game and runs its unit tests. A match on the beta channel plays it.

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.tsx and 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;
  • 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

Every build runs each #[test] function in the game logic, so validate_code and commit both run them. 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 seed plays a game to its end, then checks that a move after it is refused:

#[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.
  • #[should_panic] is refused, because a game panics by aborting. Assert on the Err the game answers instead.
  • #[ignore] skips a test. It counts as ignored.
  • A test sees no files, no environment, a clock that starts at zero and randomness of zeros. Draw randomness from a Seed, as the game logic does.

The limits of a test run are in Builds, limits and errors.

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. A file the client never imports is not checked, 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() returns null until the match arrives. Check for null before you read the board.
  • fetch, window, document and localStorage do 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, against the client types that build gives. When the game logic does not build, boardweaver/game holds the client types of the last commit, so it would report a field you just added as missing: 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 but the unit tests, so it finds a wrong intent, a missing reveal or a panic only where a test reaches it. Check those by reading the code against Offers, intents and decisions and Hidden information, and write a test for each one you fix.

How to read its errors: Builds, limits and errors.

Playing a session

Play the game logic with the play tools before you commit. A session runs the worktree's game logic in Boardweaver's game runner. It writes no match and changes nothing in the game.

  1. Call start_game with the game's id. It builds the game logic as commit does, seats players 1 to numPlayers or starts at a state you pass, and answers each active player's offers.
  2. Call apply_action with a player id and one offer's action. A step that ends the decision commits it. Any other step leaves the decision open, and the answer lists the offers after it.
  3. Call inspect_state to read the true state. Pass viewer to read what one seat or a spectator sees.
  4. Call set_state to write a state, or one path in it such as metaData.round, to test a position without playing to it.
  5. Call render_game to see the client as one seat sees the board: format: "html" for its markup and CSS, format: "image" for a screenshot. While a seat has an open decision, that seat sees the board after its steps. start_game builds the client with the session, so start a new session after you edit it. A client that does not build leaves the session unrenderable, and start_game's warnings says why.

Use a session to check what a build cannot: that each state offers what the rules allow, that each decision ends where it should (Offers, intents and decisions), and that each seat's view hides what it must (Hidden information).

A render shows one board at rest: no steps play, and the client can take no action. Check moves, drafts and Undo in a match on the beta channel.

Playing on the beta channel

Each commit becomes the game's beta channel. A person plays 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 move, 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 move fails in a match, the game's studio lists the match error with its code (GAME_FAILED, EXECUTION_TIMEOUT) and the panic message.