Web3 GameFi platform on Ethereum Sepolia. Two games:
- E-Card — asymmetric card game (Emperor vs Citizens). Players stake ETH; on-chain escrow holds funds; server oracle settles on game end.
- Rock-Paper-Scissors — best-of-3. Same stake/settle mechanic.
Both support a free "Play vs Bot" mode that requires no wallet. Weekly ELO-based leaderboard rewards distributed via Merkle airdrop (ERC-20 RewardToken).
Tech stack: Next.js 16 (App Router), Drizzle ORM + Supabase Postgres, viem/wagmi, Foundry, Tailwind CSS.
| Tool | Version | Purpose |
|---|---|---|
| mise | any | Installs node/pnpm/foundry |
| MetaMask | any | Browser wallet (for staked play) |
| Supabase account | — | Hosted Postgres database |
| Sepolia ETH | ~0.05 ETH | Gas + stake testing |
cp .env.example .env.localFill in .env.local:
DATABASE_URL="postgres://USER:PASS@HOST:6543/postgres?sslmode=require"
NEXT_PUBLIC_CHAIN_ID=11155111
NEXT_PUBLIC_RPC_URL="https://sepolia.infura.io/v3/KEY"
ORACLE_PRIVATE_KEY="0x..." # server wallet; NEVER expose publicly
NEXT_PUBLIC_ESCROW_ADDRESS="0x..." # filled after deploy
NEXT_PUBLIC_REWARD_TOKEN_ADDRESS="0x..." # filled after deploy
NEXT_PUBLIC_DISTRIBUTOR_ADDRESS="0x..." # filled after deploy
CRON_SECRET="long-random-string" # guards /api/cron/* endpoints
The ORACLE_PRIVATE_KEY wallet must have Sepolia ETH to pay gas for settle transactions. Keep it server-side only — it has full power to settle any match.
Also set FEE_RECIPIENT (any address) when deploying contracts (see below).
mise installInstalls Node 22, pnpm 9, and Foundry (forge/cast/anvil) as specified in mise.toml.
cd contracts
forge testcd contracts
forge script script/Deploy.s.sol \
--rpc-url $NEXT_PUBLIC_RPC_URL \
--broadcast \
--private-key $ORACLE_PRIVATE_KEYThe script reads ORACLE_PRIVATE_KEY and FEE_RECIPIENT from the environment. Copy the three printed addresses into .env.local:
NEXT_PUBLIC_ESCROW_ADDRESS=<ESCROW printed by script>
NEXT_PUBLIC_REWARD_TOKEN_ADDRESS=<TOKEN printed by script>
NEXT_PUBLIC_DISTRIBUTOR_ADDRESS=<DISTRIBUTOR printed by script>
MerkleDistributor does not mint tokens — it distributes tokens already held in its balance. After deploy, manually mint RewardToken to the distributor address:
cast send $NEXT_PUBLIC_REWARD_TOKEN_ADDRESS \
"mint(address,uint256)" \
$NEXT_PUBLIC_DISTRIBUTOR_ADDRESS \
<amount_in_wei> \
--rpc-url $NEXT_PUBLIC_RPC_URL \
--private-key $ORACLE_PRIVATE_KEYRewardToken is Ownable; the deployer wallet (oracle key) is the owner and can mint.
Push the Drizzle schema to Supabase (requires DATABASE_URL in .env.local):
pnpm drizzle-kit pushpnpm devOpen http://localhost:3000.
- Connect MetaMask (Sepolia network).
- Wallet A: go to
/play/ecardor/play/rps, click Create Match, set stake amount → MetaMask prompts to send ETH to the escrow. - Wallet B: open the same match URL, click Join Match → MetaMask prompts to match the stake.
- Both players submit moves each round via the board UI (no on-chain tx per move — moves are server-authoritative).
- On game end, the server oracle automatically calls
settleMatch()on-chain, paying the winner2×stake − 10% fee.
Click Play vs Bot (free) on the game page. No MetaMask required, no ETH at stake. Bot moves are generated server-side.
The cron job runs every Monday at 00:00 UTC (configured in vercel.json). It computes the top-10 ELO players per game, builds a Merkle tree, stores claims in the DB, and pushes the root on-chain.
Trigger manually:
curl -X POST http://localhost:3000/api/cron/rewards \
-H "x-cron-secret: $CRON_SECRET"Claim rewards — navigate to /leaderboard/rps?week=<n> or /leaderboard/ecard?week=<n>. Eligible wallets see a Claim button that calls MerkleDistributor.claim() via wagmi.
Oracle key trust — The ORACLE_PRIVATE_KEY server wallet can settle any match on-chain. There is no on-chain commit-reveal or ZK proof. Sepolia-only; not production-safe.
No frontend/engine unit tests — Only Foundry contract tests exist (forge test). The Next.js app and game engine have no automated tests.
Orphan open DB rows — If a user's createMatch transaction fails or is abandoned after the DB row is inserted, the row stays in status=open indefinitely. Prune manually:
DELETE FROM matches WHERE status = 'open' AND created_at < now() - interval '1 hour';Score label perspective — The board shows "You / Opp" scores from the creator's perspective. A player joining as opponent sees the labels reversed.
Per-turn forfeit timeout — If a player stops responding mid-game, forfeit must be triggered manually (or via cron):
curl -X POST http://localhost:3000/api/matches/<id>/settle \
-H "x-cron-secret: $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"winner":"<other_player_address>"}'After 6 hours of inactivity the on-chain refundMatch() function becomes callable by anyone, returning each player's stake.
Vercel cron vs POST endpoint — vercel.json schedules a GET request to /api/cron/rewards, but the route only handles POST with x-cron-secret. Use a Vercel cron proxy function or trigger manually via curl.
-
forge testpasses (all contract tests green) -
.env.localhas all six required variables filled -
pnpm drizzle-kit pushsucceeds (tables visible in Supabase) -
pnpm devstarts without errors - MetaMask connects on Sepolia (chain ID 11155111)
- Create match → join → play → auto-settle completes (check DB
status=settled, on-chain tx visible on Sepolia Etherscan) - Bot game completes without wallet
- Manual cron curl returns
{ week, roots }JSON - Leaderboard page shows claim button for rewarded wallet