🎲Game Development

Developing games on RACE Protocol revolves around creating a core piece of logic called a Game Handler. This handler is written in Rust, compiled to WebAssembly (WASM), and acts as the definitive state machine for your game. The protocol is designed so you can focus purely on your game's rules and state, while the Transactor network handles the complexities of blockchain interaction, networking, and security.

This section will guide you through the process of building, testing, and understanding the core components of a RACE game.

Getting Started: Your First Game Handler

Every game on RACE is a Rust library project that implements the GameHandler trait.

1. Project Setup

First, create a new Rust library. This library will contain your game's logic.

cargo new my_awesome_game --lib

2. Configure Cargo.toml

Your game needs to be compiled as a cdylib (a dynamic library format suitable for WASM) and an rlib (for integration testing). You'll also need to add the necessary RACE crates as dependencies.

# In your game's Cargo.toml

[package]
name = "my_awesome_game"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
# The core API for building a game handler
race-api = { workspace = true }
# The procedural macro for WASM boilerplate
race-proc-macro = { workspace = true }
# For serializing and deserializing your game state
borsh = { workspace = true }

# Add serde if you plan to use JSON for custom events
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

[dev-dependencies]
# The race-test crate is essential for testing
race-test = { workspace = true }
anyhow = { workspace = true }

3. The Basic Structure

Your game logic will live in src/lib.rs. The core structure consists of:

  1. A struct that holds your game's state, marked with #[game_handler].

  2. An implementation of the GameHandler trait for that struct.

Here is a minimal, complete example based on examples/minimal:

Implementing the GameHandler Trait

Your game's entire logic is contained within the implementation of the GameHandler trait. This trait is the contract between your game and the RACE runtime.

The canonical definition is found in race-repo/api/src/engine.rs:

State Management

The struct that implements GameHandler is your game's state. It must derive BorshSerialize and BorshDeserialize so the runtime can save and load it between event executions.

init_state

This function is called once when a game room is first loaded by the Transactor. It initializes your game's state from data stored in the on-chain GameAccount.

handle_event

This is the main entry point for your game's logic. The runtime calls this function for every event that occurs. Your job is to update your state struct based on the event and use the effect object to request any side effects.

balances

This function is called by the runtime just before a checkpoint is created. It must return the current in-game chip/token balance of every player involved in the game. This is crucial for the settlement process to correctly calculate payouts and verify the integrity of the game's economy.

Using Effects for Game Actions

The Effect object is your sole gateway to the world outside your pure WASM logic. You call its methods to describe the actions you want the runtime to perform. The runtime then executes these actions on your behalf.

Randomness

RACE uses a multi-party computation (MPC) protocol for fair and verifiable randomness.

  • effect.init_random_state(spec: RandomSpec) -> RandomId: Requests a new source of randomness. RandomSpec can be a ShuffledList (like a deck of cards) or a Lottery. This returns a RandomId you must store in your state.

  • effect.assign(random_id, player_id, indices): Assigns a secret random value (e.g., a card) to a specific player. Only that player will be able to decrypt it.

  • effect.reveal(random_id, indices): Reveals a random value to everyone (e.g., community cards).

  • effect.get_revealed(random_id) -> HandleResult<&HashMap<usize, String>>: After an Event::SecretsReady is received, this function lets you access the revealed values.

Player Decisions

For actions that must be committed secretly (like choosing Rock, Paper, or Scissors), use the decision mechanism.

  • effect.ask(player_id) -> DecisionId: Asks a player to make a hidden, binding decision. Store the returned DecisionId.

  • effect.release(decision_id): Requests the reveal of a previously made decision.

  • effect.get_answer(decision_id) -> HandleResult<&str>: After an Event::SecretsReady, use this to get the plaintext answer for a decision.

Timeouts

  • effect.action_timeout(player_id, duration_ms): Starts a timer for a specific player. If they don't act in time, an Event::ActionTimeout will be sent to your handler.

  • effect.wait_timeout(duration_ms): Starts a general-purpose timer. When it expires, an Event::WaitingTimeout is sent.

Settlements & Player Management

These actions are used to manage player funds and status. Calling any of these methods implicitly marks the current state as a checkpoint, preparing it for an on-chain settlement transaction.

  • effect.withdraw(player_id, amount): A player cashes out amount from their in-game balance to their wallet.

  • effect.eject(player_id): Removes a player from the game. Their balance must be handled via withdraw.

  • effect.transfer(amount): Transfers amount from the game's collective pot to the designated on-chain recipient account (e.g., for rake or commissions).

  • effect.award(player_id, bonus_identifier): Awards a player a specific bonus (e.g., an NFT prize) identified by a string.

  • effect.accept_deposit(deposit) / effect.reject_deposit(deposit): Handles in-game deposits made by players after they've already joined.

Example: Ending a Poker Hand

Logging

You can print logs from your game handler for debugging. These will appear in the Transactor's logs.

  • effect.info("message"), effect.warn("message"), effect.error("message")


Defining Custom Events

To handle player actions, you define your own event enum and implement the CustomEvent trait.


Testing Your Game

The race-test crate is indispensable for writing correct and secure game logic. It provides helpers to simulate the entire game lifecycle without needing a live blockchain or Transactor.

Key Components of the Test Kit

  • TestClient: Simulates a player or a server node (transactor, validator). It manages its own secrets for randomization and decisions, mimicking the behavior of a real client.

  • TestContextBuilder: A convenient builder for setting up a mock GameAccount with players, servers, and initial on-chain data for your tests.

  • TestHandler: A wrapper around your GameHandler that simulates the RACE runtime. It processes events and automatically handles the back-and-forth of system events (like Mask, Lock, ShareSecrets) that are generated by effects like randomization.

Integration Test Example Flow

The integration test for the draw-card example (examples/draw-card/src/integration_test.rs) is the best reference. Here's a conceptual overview:

  1. Setup: Create TestClient instances for each participant. Use TestContextBuilder to configure and build the initial GameContext and TestHandler.

  2. Simulate Events: Create and handle events as they would occur in a real game.

  3. Use the Simulation Loop: For complex interactions like randomization, instead of handling each system event manually, use handle_until_no_events. This powerful function simulates the entire back-and-forth between the game handler and the clients until the game is waiting for the next player action.

By following this pattern, you can write concise and powerful integration tests that cover the entire lifecycle of your game, ensuring all logic, state transitions, and settlements work as expected.

Last updated