PvP dice-rolling betting game built as a MiniApp for MiniPay, running on Celo mainnet.
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?
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.prevrandaocombined 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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
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.
The game uses a commit-reveal + prevrandao pattern:
- 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. - Player B joins without knowing the secret.
- 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.
- 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 —
RoomandPlayeraggregate entities over all game events - GraphQL client:
graphql-request+ typed queries inlib/indexer.ts - Avatars:
minidenticons— deterministic SVG identicons, zero deps, SSR-safe - OG images:
next/ogedge 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
- Foundry (
curl -L https://foundry.paradigm.xyz | bash && foundryup) - Node.js 22+
- pnpm 9+ (
npm i -g pnpm)
# 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# 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 buildThe 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 buildThen start the indexer:
cd ../
pnpm dev
# GraphQL playground: http://localhost:8080/console (password: testing)
# GraphQL endpoint: http://localhost:8080/v1/graphqlIf envio dev fails with Cannot find module './src/db/Migrations.res.js':
cd packages/envio-indexer/generated
pnpm install --ignore-workspace @envio-dev/hypersync-clientAfter 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 migrationsSet 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=testingThe 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.
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 mainnetAfter 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 mainnetAfter deploying, set NEXT_PUBLIC_TOURNAMENT_ADDRESS in your env and call fundDay(dayId, amount) to seed the first prize pool.
To test inside the MiniPay wallet on your phone:
pnpm dev— starts Next.js onlocalhost:3000- Tunnel with ngrok:
ngrok http 3000 --domain=your-static-domain.ngrok-free.app - Open MiniPay → compass icon → "Test Page"
- Paste the ngrok URL and tap "Go"
- The app auto-connects — no "Connect Wallet" button needed
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.prevrandaoto exercise randomness paths deterministically
Run with:
pnpm --filter contracts test:fuzz| Network | Address | Verification |
|---|---|---|
| Celo mainnet | 0xe40245B53Fc2c471CD9b50E8662f3A2aD63e88EF |
Celoscan |
| Celo Sepolia | 0x5803454e1abE598Bd525a613bb5034b4aE192590 |
Celoscan Sepolia |
| Network | Address | Verification |
|---|---|---|
| Celo mainnet | 0x9F74B63a23CCdc314840f5aA0Bd8c8Ac9Dd78257 |
Celoscan |
| Celo Sepolia | 0x8978E35d0755b082C7a77A5FF34dBdE477372915 |
Celoscan Sepolia |
Every week (Sunday → Saturday) the top 3 players by wins compete for a USDT prize pool funded by the protocol.
| 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 |
| 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.
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.
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.
Víctor Hugo Mosquera Alvarado (Cappy) Senior Full-Stack Web & Web3 Developer — Bogotá, Colombia
MIT — see LICENSE.
Built during Proof of Ship April 2026 edition. Thanks to the Celo DevRel team and the MiniPay team for the tooling and support.