Skip to content

Repository files navigation

Dice Battle 🎲

PvP dice-rolling betting game built as a MiniApp for MiniPay, running on Celo mainnet.

CI Celo MiniPay Foundry License: MIT

The problem

Casual peer-to-peer betting in Latin America happens constantly — pick a number, roll a dice, whoever's closest wins the coffee. But doing it with real stakes among friends requires someone to trust, someone to hold the pot, and someone to pay out. Every informal "apuesta" eventually hits the same wall: who is the bookkeeper?

The solution

Dice Battle is a two-player dice battle where the escrow, the randomness, and the payout all live onchain. Stake 1 cUSD or USDT, invite a friend, the contract rolls four dice (two per player) using verifiable onchain entropy — the player with the highest sum wins the pot minus a 2% protocol fee — all inside MiniPay, paid in stablecoins, with sub-cent gas.

  • No bookie. The smart contract is the escrow.
  • Provably fair. Uses block.prevrandao combined with a commit-reveal scheme so neither player can cheat.
  • Stablecoin native. cUSD, USDT and USDC supported on day one.
  • MiniPay-first. Detects the MiniPay environment and hides all wallet UX.
  • Gas in stablecoins. Users pay fees in cUSD via CIP-64 fee abstraction.

Features

Core game

Feature Details
PvP dice battle Two players, four dice (two each), highest sum wins
Commit-reveal fairness Player A locks a secret hash before Player B joins — neither can manipulate the outcome
block.prevrandao entropy On-chain randomness unknown at commit time, preventing cherry-picking
Instant payout Winner receives funds in the same transaction that reveals the result
Grief protection If Player A stalls, Player B claims the full pot after a 200-block window (~16 min)
Multi-token stakes cUSD (USDm), USDT and USDC — any amount above 0
2% protocol fee Applied to the pot on win/claim; refunded in full on a tie
Secret recovery Lost your secret? Paste the hex from another device directly in the game UI

UX & social

Feature Details
Quick Match Auto-joins the first available open room with one tap
Live stats bar Open rooms, your active rooms, games today, all-time counter — refreshes every 30 s
Dynamic OG images Every game gets a shareable /api/og/[roomId] card with dice rolls and winner
Sound engine Dice roll, win, loss and tie sounds — per-user mute toggle persisted in localStorage
i18n English + Spanish with automatic browser locale detection
MiniPay auto-connect No "Connect Wallet" button — wallet detected and connected silently

Player progression

Feature Details
Player profiles Win/loss/tie stats, volume, current streak, head-to-head history
13 achievements Four rarity tiers (Common → Legendary) with live progress bars
28-day activity calendar Heatmap of play days visible on your own profile
Daily streak badge Streak counter in the WalletBar; highlights at 3+ day streaks
Head-to-head stats Per-opponent W/L/T record shown in the game room

Leaderboard & tournament

Feature Details
Global leaderboard Today / Week / All-time tabs, sortable by Wins, Win Rate or Volume
Weekly tournament Top 3 players by wins split a USDT prize pool (60 / 25 / 15 %)
Automated finalization Vercel Cron calls the contract every Sunday at 00:00 UTC
On-chain prize claim Winners claim directly from the DailyTournament contract

Developer & protocol

Feature Details
Protocol stats page Live TVL, volume by token, recent game activity
Envio indexer Room and Player aggregate entities, GraphQL API
ABI auto-sync pnpm sync-abi copies the Foundry build output to the frontend — no manual work
CI/CD GitHub Actions: contract fuzz (10 000 runs) + TypeScript build in parallel
Fully open source MIT license, monorepo, documented deploy scripts

Monorepo layout

dice-battle/
├── apps/
│   └── web/                        # Next.js 16 frontend with wagmi + viem
│       ├── app/
│       │   ├── page.tsx            # Home — create / browse / leaderboard + LiveStats
│       │   ├── create/             # Create a room
│       │   ├── join/[roomId]/      # Join a room (Player B flow)
│       │   ├── game/[roomId]/      # Live game + reveal + results + Share button
│       │   │   ├── layout.tsx      # generateMetadata with dynamic OG image
│       │   │   └── page.tsx        # "use client" game UI
│       │   ├── rooms/              # Browse open rooms — paginated (10/page) + fixed CTA
│       │   ├── profile/[address]/  # Player profile with stats, achievements, history & 28-day activity calendar
│       │   ├── leaderboard/        # Global leaderboard (Today/Week/All-time)
│       │   ├── tournament/         # Weekly tournament — pool, countdown, leaderboard, claim prizes
│       │   ├── api/
│       │   │   ├── cron/finalize-tournament/  # Vercel Cron: finalizes Saturday's tournament (Sun 00:00 UTC)
│       │   │   └── og/[roomId]/    # Edge route — dynamic share card image
│       ├── components/
│       │   ├── WalletBar.tsx       # Wallet status + avatar link to profile + streak badge + mute toggle
│       │   ├── game/               # DiceAnimation, DicePair, SecretBackupModal
│       │   ├── social/             # LiveStats banner (open rooms, games today, all-time)
│       │   └── ui/                 # shadcn: Dialog, Pagination, Skeleton, Spinner + Identicon
│       ├── config/                 # Wagmi & Reown AppKit config
│       ├── hooks/                  # useMiniPay, useErrorToast, useSoundEngine, useDailyStreak
│       └── lib/
│           ├── abi.ts              # DiceBattle ABI (auto-synced)
│           ├── achievements.ts     # 13 achievements — PlayerStats, buildPlayerStats, ACHIEVEMENTS
│           ├── commitment.ts       # Secret generate / store / load / clear
│           ├── constants.ts        # Addresses, ROOM_STATE, token decimals, TOURNAMENT_ADDRESS
│           ├── dailyStreak.ts      # Daily streak logic — read/write play days in localStorage
│           ├── indexer.ts          # GraphQL client + 9 typed queries (stats, H2H, leaderboard…)
│           ├── logger.ts           # Dev-only logger
│           ├── sounds.ts           # Audio assets — dice roll, win, loss, tie
│           └── utils.ts            # truncateAddress, getTokenSymbol, timeAgo, cn
├── packages/
│   ├── contracts/                  # Foundry project
│   │   ├── src/                    # DiceBattle.sol, DailyTournament.sol, MockERC20.sol
│   │   ├── test/                   # Full test suite with fuzzing
│   │   ├── script/                 # Deploy.s.sol, DeployTournament.s.sol
│   │   └── docs/                   # Deploy guides
│   └── envio-indexer/              # Envio event indexer
│       ├── config.yaml             # Network + contract + field_selection
│       ├── schema.graphql          # Room + Player aggregates + raw event tables
│       └── src/EventHandlers.ts    # State machine handlers per event
├── docs/
│   ├── tournament.md               # Full weekly tournament flow & operational guide
│   └── branching-strategy.md
├── scripts/
│   └── sync-abi.mjs                # Copies ABI from Foundry build → frontend
├── .github/workflows/              # CI (contracts + web in parallel)
├── package.json                    # Root with pnpm workspaces
└── pnpm-workspace.yaml

The monorepo is managed with pnpm workspaces. The contract ABI is automatically synced from the Foundry build output to the frontend via pnpm sync-abi — no manual copy-paste ever.

Randomness model

The game uses a commit-reveal + prevrandao pattern:

  1. Player A commits keccak256(abi.encode(secret, playerA)) when creating the room. The secret is generated in the browser and stored in localStorage — it never hits the blockchain until reveal.
  2. Player B joins without knowing the secret.
  3. Player A reveals the secret. The contract derives four dice rolls (two per player) from keccak256(secret, prevrandao, playerB, roomId). Each roll uses an independent byte of the 256-bit seed. The player with the highest sum wins.

This gives us:

  • Player A cannot predict the outcome (prevrandao was not known when A committed).
  • Player B cannot influence the outcome (A's commitment was locked before B joined).
  • The block proposer has at most 1 bit of influence — acceptable for micro-stakes (max 5 USDT). For high-stakes games, a VRF oracle would be needed.

Grief protection: if Player A stalls on reveal, Player B can claim the full pot after a 200-block window (~16 min on Celo) via claimExpired. Both players see a live block countdown in the game UI.

Tech stack

  • Smart contracts: Solidity 0.8.24, OpenZeppelin 5.0, Foundry, viaIR
  • Testing: Foundry (unit + fuzz + invariants) — 10 000 fuzz runs in CI
  • Randomness: block.prevrandao + commit-reveal binding
  • Frontend: Next.js 16 App Router, React 19, TypeScript strict
  • Onchain lib: viem + wagmi (NOT ethers.js — incompatible with MiniPay)
  • Styling: Tailwind CSS v3 + shadcn/ui (Dialog, Skeleton)
  • Animations: Framer Motion (dice roll phases with spring settle)
  • Sound: Web Audio API — dice roll, win, loss and tie sounds with per-user mute toggle (persisted in localStorage)
  • Daily streak: localStorage-based activity tracking with 28-day calendar on the profile page
  • Indexer: Envio — Room and Player aggregate entities over all game events
  • GraphQL client: graphql-request + typed queries in lib/indexer.ts
  • Avatars: minidenticons — deterministic SVG identicons, zero deps, SSR-safe
  • OG images: next/og edge route at /api/og/[roomId] — dynamic share cards with dice results
  • Deployment: Vercel (frontend), Celo mainnet (contract)
  • CI/CD: GitHub Actions with parallel jobs for contracts and web

Quick start

Prerequisites

  • Foundry (curl -L https://foundry.paradigm.xyz | bash && foundryup)
  • Node.js 22+
  • pnpm 9+ (npm i -g pnpm)

Setup

# Clone
git clone https://github.com/viktorhugo/dice-battle
cd dice-battle

# Install all JS deps across the workspace
pnpm install

# Install Foundry deps (forge-std + OpenZeppelin as git submodules)
cd packages/contracts
make install
cd ../..

# Copy env files
cp .env.example .env
cp apps/web/.env.example apps/web/.env.local

Daily dev loop

# Run contract tests
pnpm test:contracts

# Run frontend dev server
pnpm dev

# Lint and format everything
pnpm lint
pnpm fmt

# Run everything in CI mode (higher fuzz runs, type checks, build)
pnpm test
pnpm build

Envio indexer local dev

The indexer maintains two aggregate entities — Room (full state machine) and Player (leaderboard stats) — derived from on-chain events. The frontend reads from it via GraphQL (lib/indexer.ts).

The packages/envio-indexer package has a generated ReScript subpackage. Build it first:

cd packages/envio-indexer/generated
pnpm install --ignore-workspace
pnpm build

Then start the indexer:

cd ../
pnpm dev
# GraphQL playground: http://localhost:8080/console  (password: testing)
# GraphQL endpoint:   http://localhost:8080/v1/graphql

If envio dev fails with Cannot find module './src/db/Migrations.res.js':

cd packages/envio-indexer/generated
pnpm install --ignore-workspace @envio-dev/hypersync-client

After changing schema.graphql or config.yaml, regenerate types and restart:

npx envio codegen   # re-generates types in generated/
npx envio stop
npx envio dev       # applies new schema migrations

Set NEXT_PUBLIC_INDEXER_URL in apps/web/.env.local to point the frontend at the indexer:

NEXT_PUBLIC_INDEXER_URL=http://localhost:8080/v1/graphql
INDEXER_ADMIN_SECRET=testing

The hosted Envio indexer (production) is deployed from the envio branch. Only push to envio when you want to redeploy the indexer — pushes to master do not trigger it.

Deploying

The deploy scripts use Foundry's encrypted keystore — no private key in environment variables.

# 1. Import your deployer wallet once (you'll be prompted for a password)
cast wallet import celo-sepolia --private-key <YOUR_PRIVATE_KEY>   # testnet
cast wallet import celo-mainnet --private-key <YOUR_PRIVATE_KEY>  # mainnet

# 2. Deploy (password prompt at runtime)
pnpm deploy:sepolia   # Celo Sepolia
pnpm deploy:mainnet   # Celo mainnet

After each deploy, update apps/web/.env.local with the new contract address.

To deploy the DailyTournament contract (daily prize pool):

cd packages/contracts
make deploy-tournament-sepolia   # Celo Sepolia
make deploy-tournament-mainnet   # Celo mainnet

After deploying, set NEXT_PUBLIC_TOURNAMENT_ADDRESS in your env and call fundDay(dayId, amount) to seed the first prize pool.

Running in MiniPay

To test inside the MiniPay wallet on your phone:

  1. pnpm dev — starts Next.js on localhost:3000
  2. Tunnel with ngrok: ngrok http 3000 --domain=your-static-domain.ngrok-free.app
  3. Open MiniPay → compass icon → "Test Page"
  4. Paste the ngrok URL and tap "Go"
  5. The app auto-connects — no "Connect Wallet" button needed

Testing philosophy

The contract test suite includes:

  • Unit tests for every happy path and revert case
  • Fuzz tests (1 000 runs locally, 10 000 in CI) for:
    • Token leak invariant (contract holds exactly accrued fees, always)
    • Dice roll range (always 1-6 for any secret)
    • Fee math correctness across any stake from 1 wei to 1M tokens
  • Cheatcodes like vm.prevrandao to exercise randomness paths deterministically

Run with:

pnpm --filter contracts test:fuzz

Deployed contracts

DiceBattle

Network Address Verification
Celo mainnet 0xe40245B53Fc2c471CD9b50E8662f3A2aD63e88EF Celoscan
Celo Sepolia 0x5803454e1abE598Bd525a613bb5034b4aE192590 Celoscan Sepolia

DailyTournament

Network Address Verification
Celo mainnet 0x9F74B63a23CCdc314840f5aA0Bd8c8Ac9Dd78257 Celoscan
Celo Sepolia 0x8978E35d0755b082C7a77A5FF34dBdE477372915 Celoscan Sepolia

Weekly Tournament

Every week (Sunday → Saturday) the top 3 players by wins compete for a USDT prize pool funded by the protocol.

How it works

Step Who When
Fund the pool Owner calls fundDay(saturdayDayId, amount) Before Saturday
Play games Any player All week
Finalize Vercel Cron hits /api/cron/finalize-tournament Sunday 00:00 UTC
Claim prizes Winners visit /tournament and click Claim Any time after finalization

Prize split

Place Share
🥇 1st 60%
🥈 2nd 25%
🥉 3rd 15%

Minimum 5 games to qualify. Ranked by wins, winRate as tiebreaker. Empty ranks (fewer than 3 qualifiers) are returned to the owner immediately — no 30-day wait.

Required env vars

NEXT_PUBLIC_TOURNAMENT_ADDRESS=   # DailyTournament contract address
CRON_SECRET=                      # Vercel Cron auth secret (openssl rand -base64 24)
TOURNAMENT_OWNER_PRIVATE_KEY=     # Owner wallet private key (server-side only)
INDEXER_ADMIN_SECRET=             # Hasura admin secret (server-side only)

See docs/tournament.md for the full operational guide.

Why this belongs on MiniPay

MiniPay's user base transacts in stablecoins every day — 87% of peer-to-peer transactions are under $5. Dice Battle is built for exactly that pattern: fast, low-stakes, social transactions with a fun wrapper. A dice battle between friends is a 3-transaction flow that takes ~30 seconds. That's onchain activity MiniPay users actually want.

Author

Víctor Hugo Mosquera Alvarado (Cappy) Senior Full-Stack Web & Web3 Developer — Bogotá, Colombia

License

MIT — see LICENSE.

Acknowledgements

Built during Proof of Ship April 2026 edition. Thanks to the Celo DevRel team and the MiniPay team for the tooling and support.

Releases

Packages

Contributors

Languages