Liar's Dice is a PvP bluffing game on Stellar where two players secretly roll dice, bid on the total count of a face across both hands, and call each other's bluffs. Zero-knowledge proofs keep hands hidden while guaranteeing fair play — no trust required. Lose a die each time you're wrong; lose them all and the game is over.
Both players start with 5 dice. Each round:
- Dice are rolled secretly — nobody sees the other player's dice
- Before bidding, each player receives the sum of the opponent's dice (without the individual values)
- Players take turns bidding: "There are at least N dice showing face X across both hands"
- Each bid must exceed the previous one (higher quantity, or same quantity with a higher face)
- Stars (face 1) are wildcards — they count as any face, except when bidding on stars
- Instead of bidding, you can call "Liar!" — counts are revealed and the round resolves:
- If the actual total is equal to or greater than the bid → the challenger loses a die
- If the actual total is less than the bid → the bidder loses a die
The player who reaches 0 dice loses.
Each round follows a 5-phase cryptographic protocol:
Each player generates a random nonce and commits Poseidon2(nonce, 0, 0, 0) to the contract. The nonce stays secret. It's committed before the seed exists — so neither party can influence the roll.
The contract generates a random seed via PRNG. Each player derives their dice deterministically:
die[i] = (last_byte(Poseidon2(seed, nonce, i, 0)) % 6) + 1
Then generates a ZK proof (dice_roll circuit) that proves:
- The nonce matches the commitment from phase 1
- Each die was correctly derived from
seed + nonce + index - The dice commitment (Poseidon2 hash of the packed set) is valid
Verified on-chain. Nobody chooses their dice.
Before bidding begins, both players generate a ZK proof (dice_sum circuit) that proves the total sum of their dice without revealing individual values.
Each player sees the opponent's sum as strategic information: if the opponent's sum is 25 (with 5 dice), they probably have high dice. If it's 8, probably low. But the exact values remain secret.
This is what ZK enables and commit-reveal cannot: proving an aggregate property of private data without exposing the data.
Players take turns bidding: "There are at least N dice showing face X across both hands."
Rules:
- Each bid must exceed the previous one (higher quantity, or same quantity with a higher face)
- Stars (face 1) are wildcards — they count as any face, except when bidding on stars
- Each player sees the opponent's sum (revealed in phase 3) to inform their strategy
- When someone believes the bid is a bluff → "Liar!"
Both players generate a ZK proof (dice_count circuit) that proves:
- The dice match the commitment from phase 2 (they can't be swapped)
- The count of dice matching the challenged face is exactly N
- Stars are correctly counted as wildcards
The contract sums both counts:
total >= bid_quantity→ the bid was correct, the challenger loses a dietotal < bid_quantity→ it was a bluff, the bidder loses a die
When a player reaches 0 dice, the game ends. Otherwise, new round.
Utility for computing Poseidon2 hashes. Used for nonce and dice commitments.
| Type | Inputs |
|---|---|
| Public | nonce_commitment, seed, dice_commitment, num_dice |
| Private | nonce, dice[5], dice_nonce |
The circuit enforces: the nonce matches its commitment, each die is derived from Poseidon2(seed, nonce, i, 0), active dice are in [1,6], inactive ones are 0, and the dice commitment is valid.
The contract extracts public inputs from the proof blob and validates them against the game state:
| Public Input | Contract Validation | What It Prevents |
|---|---|---|
nonce_commitment |
Must match the commitment stored in phase 1 | Using a different nonce to manipulate the roll |
seed |
Must match game.seed |
Using a different seed to get more favorable dice |
dice_commitment |
Must match the argument sent in the TX | Decoupling the commitment from the proof |
num_dice |
Must match the player's current dice count | Proving with more dice than they have |
| Type | Inputs |
|---|---|
| Public | dice_commitment, claimed_sum, num_dice |
| Private | dice[5], dice_nonce |
The circuit enforces: the commitment matches the one from the roll phase, the sum of active dice is exactly claimed_sum, active dice are in [1,6], and inactive ones are 0.
The contract validates:
| Public Input | Contract Validation | What It Prevents |
|---|---|---|
dice_commitment |
Must match the commitment stored in phase 2 | Proving the sum of different dice than committed |
claimed_sum |
Extracted and stored for the opponent | — |
num_dice |
Must match the player's current dice count | Summing more or fewer dice than they have |
| Type | Inputs |
|---|---|
| Public | dice_commitment, face_value, claimed_count, num_dice |
| Private | dice[5], dice_nonce |
The circuit enforces: the commitment matches the one from the roll phase, the count is computed correctly (including the stars wildcard rule), and all values are in valid range.
The contract validates:
| Public Input | Contract Validation | What It Prevents |
|---|---|---|
dice_commitment |
Must match the commitment stored in phase 2 | Swapping dice between the roll and the reveal |
face_value |
Must match game.challenge_face |
Proving the count for a different face than challenged |
claimed_count |
Extracted and used for resolution | — |
num_dice |
Must match the player's current dice count | Counting more or fewer dice than they have |
Commit-reveal can solve fair dice derivation: the player commits hash(nonce), the contract generates a seed, and when revealing the nonce anyone can re-derive the dice. For the basic game loop (derivation + challenge), commit-reveal works.
Where ZK becomes necessary is when you want to prove partial properties of private data without exposing the data. In Liar's Dice, this manifests in the dice sum:
- Each player proves the sum of their hand before bidding
- The opponent sees the total (e.g., "sum = 16") but not the individual values
- This gives real strategic information without destroying the hand's secrecy
With commit-reveal, revealing any property means revealing the nonce, which exposes all the dice. There's no mechanism for proving a partial aggregate.
| Capability | Commit-Reveal | ZK Proofs |
|---|---|---|
| Fair dice derivation | Yes | Yes |
| Prove counts during a challenge | Yes (revealing the whole hand) | Yes (revealing nothing) |
| Prove the sum without revealing individual values | No | Yes |
| Partial property verification | No | Yes |
| Attack Vector | Defense |
|---|---|
| Choose your own dice | Deterministic derivation Poseidon2(seed, nonce, i) enforced by the circuit. Seed post-commit, nonce pre-seed. |
| Lie about your count | dice_count proof verified on-chain against committed dice. |
| Lie about your sum | dice_sum proof verified on-chain against committed dice. |
| Swap dice between phases | All three proofs reference the same dice_commitment. |
| Reuse an old proof | Public inputs include round-specific seed and nonce_commitment. |
| Front-run the seed | The nonce is committed before the contract generates the seed. |
| Forge a proof | UltraHonk verified on-chain with circuit-specific verification keys. |
- Stellar CLI v25.0.0
- Nargo v1.0.0-beta.9
- Barretenberg (bb) v0.87.0
- Rust 1.89.0 with
wasm32v1-nonetarget - Bun (or npm)
# Install dependencies
bun install
# Start local Soroban network
stellar container start local --limits unlimited
# Full deploy (fund + compile circuits + deploy contracts + update .env)
make deploy
# Dev server
bun run dev