Rock Paper Scissors with Commit-Reveal

Other Transitional
authprivacycommit-revealecs

Transitional example: This example uses an older Cougr pattern and is preserved for compatibility reference. For the current recommended approach, see snake.

A two-player Rock Paper Scissors game demonstrating the commit-reveal pattern using cryptographic hashing on Stellar Soroban. This is the simplest example of hidden information in the Cougr framework.

Status

Transitional - uses cougr-core = "1.1.0" with partial migration to impl_component! macros. See battleship for the canonical hidden-information reference.

What is Commit-Reveal?

The commit-reveal pattern solves a fundamental problem in blockchain games: how can players make simultaneous secret choices when all transactions are public?

The Problem

In a naive implementation:

  1. Player A submits "Rock" → visible on-chain
  2. Player B sees A's choice, submits "Paper" → B always wins!

The Solution: Commit-Reveal

COMMIT PHASE (hide choices)
├─ Player A: hash(Rock + secret_salt) → 0x7a3f...
├─ Player B: hash(Paper + secret_salt) → 0x9b2e...
└─ Both hashes stored on-chain (choices hidden)

REVEAL PHASE (prove choices)
├─ Player A: reveals (Rock, secret_salt)
├─ Contract: verify hash(Rock + secret_salt) == 0x7a3f... ✓
├─ Player B: reveals (Paper, secret_salt)
├─ Contract: verify hash(Paper + secret_salt) == 0x9b2e... ✓
└─ Contract: compare choices → B wins!

Key Properties:

  • ✅ Binding: Can't change choice after committing (hash locks it in)
  • ✅ Hiding: Opponent can't see choice until reveal
  • ✅ Order-independent: Neither player gains advantage by going first/second

When to Use Commit-Reveal vs ZK Circuits

Use commit-reveal (this pattern) when you need to hide simple choices that are later revealed and verified by recomputing a hash. It's simple, efficient, and doesn't require external proving systems.

Use ZK circuits when the game logic itself must remain private - for example, proving that a move is valid without revealing what the move is. See hidden_hand for a canonical ZK circuit-based example using Groth16 proofs.

Game Flow

1. Initialize Match

#![allow(unused)]
fn main() {
new_match(player_a, player_b, best_of: 3)
}

Creates a best-of-N match (1, 3, 5, etc.)

2. Commit Phase

Both players compute and submit hashes:

#![allow(unused)]
fn main() {
// Off-chain: Player A
let salt = random_32_bytes();
let hash = sha256(choice || salt);

// On-chain
commit(player_a, hash)
}

When both players commit → automatically transitions to Reveal phase

3. Reveal Phase

Players reveal their choices with salts:

#![allow(unused)]
fn main() {
reveal(player_a, choice, salt)
}

Contract verifies: sha256(choice || salt) == stored_hash

When both players reveal → automatically resolves round

4. Resolution

Rock > Scissors
Scissors > Paper
Paper > Rock
Same choice = Draw

Updates scoreboard, checks if match winner determined (best-of-N), or starts next round.

5. Timeout Protection

If a player refuses to reveal after committing:

#![allow(unused)]
fn main() {
claim_timeout(honest_player)
}

After 100 ledgers, the honest player who revealed wins by forfeit.

Contract API

Public Functions

FunctionParametersDescription
new_matchplayer_a: Address, player_b: Address, best_of: u32Initialize a new match
commitplayer: Address, hash: BytesN<32>Submit commitment hash
revealplayer: Address, choice: u32, salt: BytesN<32>Reveal choice (0=Rock, 1=Paper, 2=Scissors)
claim_timeoutplayer: AddressClaim win if opponent doesn't reveal after 100 ledgers
get_state-Get current MatchState
get_score-Get ScoreBoard

Data Types

#![allow(unused)]
fn main() {
enum Choice { Rock = 0, Paper = 1, Scissors = 2 }
enum Phase { Committing, Revealing, Resolved }

struct MatchState {
    phase: Phase,
    winner: Option<Address>,
    round: u32,
}

struct ScoreBoard {
    wins_a: u32,
    wins_b: u32,
    draws: u32,
    best_of: u32,
}
}

Storage Model

Committed State

Stored on-chain during the commit phase:

  • hash_a/b - SHA256 hash of each player's choice + random salt
  • has_commit_a/b - flags tracking which players have committed
  • commit_ledger - ledger sequence when both players committed (for timeout calculation)

Both commitments must be submitted before the game transitions to Phase::Revealing.

Revealed State

Updated during the reveal phase:

  • revealed_a/b - flags tracking which players have revealed
  • choice_a/b - actual numeric choices (0, 1, or 2)
  • scoreboard - accumulated wins/draws across rounds

Proven State

The reveal function verifies that disclosed choices match the original commitment by recomputing SHA256(choice || salt) and comparing it to the stored hash.

Component Serialization

PlayerCommitment uses the impl_component! macro for standardized serialization:

#![allow(unused)]
fn main() {
impl_component!(PlayerCommitment, "commit", Table, {
    hash: bytes32,
    revealed: bool
});
}

MatchState implements ComponentTrait manually since its Phase enum and Option<Address> fields don't map cleanly to the macro's fixed-size type system.

Building & Testing

Prerequisites

  • Rust 1.88.0+
  • Stellar CLI 25.0.0+ (optional)

Build

cargo build
cargo build --release --target wasm32v1-none

Test

cargo test

Test Coverage:

  • ✅ Match initialization
  • ✅ Commit phase transitions
  • ✅ All 9 choice combinations (RR, RP, RS, PR, PP, PS, SR, SP, SS)
  • ✅ Hash mismatch rejection
  • ✅ Best-of-3 match flow
  • ✅ Double commit prevention
  • ✅ Premature reveal prevention
  • ✅ Component trait serialization (via impl_component!)

Security Considerations

Secure

  • Commitment binding: Hash function is collision-resistant
  • Choice hiding: Preimage resistance prevents guessing
  • Replay protection: Each round requires new commitments
  • Timeout protection: Prevents griefing by non-revealing players

️ Important

  • Salt randomness: Use cryptographically secure random salts (32 bytes)

  • Salt uniqueness: Never reuse salts across rounds

  • Timeout value: 100 ledgers (~8 minutes on Stellar) - adjust for your needs

Best Practices

#![allow(unused)]
fn main() {
// ✅ Good: Random salt per round
let salt = generate_random_bytes(32);

// ❌ Bad: Predictable salt
let salt = BytesN::from_array(&env, &[0u8; 32]);

// ❌ Bad: Reused salt
let salt = player_address.to_bytes();
}

ECS Architecture

Components

ComponentFieldsPurpose
PlayerCommitmenthash: BytesN<32>
revealed: bool
Stores commitment hash
MatchStatephase: Phase
winner: Option<Address>
round: u32
Game phase tracking
ScoreBoardwins_a: u32
wins_b: u32
draws: u32
best_of: u32
Match scoring

All components implement cougr_core::component::ComponentTrait for type-safe serialization.

Systems

SystemResponsibility
CommitSystemAccepts hashes, transitions to reveal when both committed
RevealSystemVerifies `sha256(choice
ResolveSystemCompares choices, updates scoreboard
MatchSystemChecks best-of-N threshold, declares winner or starts next round

Deployment


stellar keys generate rps-deployer --network testnet --fund

# Generate funded account
stellar keys generate rps-deployer --network <NETWORK> --fund

# Build contract
cargo build --release --target wasm32v1-none

# Deploy

stellar contract deploy \
  --wasm target/wasm32v1-none/release/rock_paper_scissors.wasm \
  --source rps-deployer \
  --network <NETWORK>

Play a Game

CONTRACT_ID=<your_contract_id>

# Initialize match
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  -- new_match \
  --player_a <PLAYER_A_ADDRESS> \
  --player_b <PLAYER_B_ADDRESS> \
  --best_of 3

# Player A commits (compute hash off-chain first)
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  --source player-a \
  -- commit \
  --player <PLAYER_A_ADDRESS> \
  --hash <HASH_BYTES>

# Player B commits
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  --source player-b \
  -- commit \
  --player <PLAYER_B_ADDRESS> \
  --hash <HASH_BYTES>

# Player A reveals
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  --source player-a \
  -- reveal \
  --player <PLAYER_A_ADDRESS> \
  --choice 0 \
  --salt <SALT_BYTES>

# Player B reveals
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  --source player-b \
  -- reveal \
  --player <PLAYER_B_ADDRESS> \
  --choice 1 \
  --salt <SALT_BYTES>

# Check results
stellar contract invoke \
  --id $CONTRACT_ID \
  --network <NETWORK> \
  -- get_score

Learning Path

This example is the entry point for understanding Cougr's cryptographic primitives:

  1. Start here: Commit-reveal with SHA256 (this example)
  2. Next: Upgrade to Poseidon2 hashing (ZK-friendly)
  3. Advanced: Full ZK proofs with circuits (see examples/chess/)

Resources

License

MIT OR Apache-2.0