A living map of real prizes someone else put up. Hunt them. Crack them. First to win takes it.
A mobile-first treasure-hunt game for MiniPay on Celo. Players spend energy uncovering fog tiles on a shared grid; when someone finds a treasure tile they race other hunters through a speed minigame, and the first to crack it takes the prize.
The game is real and multiplayer. The money is not.
A server-authoritative referee owns the map, energy and every race — the client renders and forwards input, and is trusted with nothing. What's missing is the escrow contract, chain indexer and settlement relayer, so
prizeLabelis a display string and nothing moves on chain yet. That design is written up indocs/BACKEND_AND_CONTRACTS.md.
| Path | What it is |
|---|---|
src/ |
React client (Vite) — the MiniPay Mini App |
server/ |
Game referee: Node + SQLite. See server/README.md |
contracts/ |
Foundry. PlayerRegistry today; escrow later |
docs/ |
Architecture and design |
The client will not run without the server. There is no offline mode — a client that silently falls back to fake local state is indistinguishable from a working one, which makes it impossible to tell whether the server is doing anything.
# terminal 1 — the referee
cd server && npm install && npm run dev # :8787
# terminal 2 — the client
npm install && npm run dev # :5173Point the client elsewhere with VITE_API_URL=https://your-host if needed.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server with HMR |
npm run build |
Production build to dist/ |
npm run lint |
ESLint (client only; server and contracts have their own) |
home → onboarding → zone picker → 12×18 grid
│
┌── tap a fog tile (−1⚡) ──→ POST /zones/:id/tiles/:r/:c/open
│ server returns the type; everyone in the zone sees it
│
└── tap a hunt tile ──────→ preview → confirm (−3⚡)
│
POST /hunts/:id/attempts
│ server picks the block's game and returns its spec
▼
minigame + REAL rival bars over WebSocket
│
┌────────────────┴──────────────┐
hunt:resolved (you won) lost / failed
Energy gates everything: 12 max, +1 every 9s, computed server-side and never ticked. A fog tile costs 1⚡, a hunt 3⚡.
The game belongs to the block, not the player. Its type and spec are derived from the hunt's salt, so everyone racing a block plays the identical game — you don't find out which until you commit.
| Game | Challenge |
|---|---|
| Tap Challenge | Mash to the target inside the time limit |
| Math Dash | Solve N in a row; the server holds the answers and issues one question at a time |
| Sequence Dig | Tap shuffled tiles in order |
| Memory Dig | Repeat a Simon sequence — XP only, never money |
Racing is resolved on server-measured elapsed time from when the spec was sent, inside a 400ms settlement window. Not on packet arrival — otherwise the prize goes to whoever has the best connection.
React 19 + Vite 8, no router, no state library, inline styles. One hook owns everything, but it is now a client of the referee rather than a simulation of one.
src/api/
http.js— fetch wrapper; errors surface asApiErrorwith the server's machine-readable codesocket.js— one WebSocket for the app, with reconnect, room re-join, and input batchingsession.js— dev identity. Production replaces this with a PlayerRegistry session keyconfig.js— API URL and timeouts
src/hooks/useGameState.js — fetches the world, dispatches socket events, sends inputs, and
keeps optimistic render state so the UI feels instant while the server decides pass and fail.
Screens switch on state.view; overlays (HuntPreview, Minigame, WinScreen,
ConnectionGate) layer on top by z-index.
src/data/gameData.js is now presentation copy only. buildGrid, hiddenType, HUNTS,
PUZZLES and SEEDS are gone — the grid used to be computable from the bundle, so every
treasure location was readable from devtools.
Neo-brutalist: hard black borders, offset drop shadows, no rounded corners. Archivo Black
(display), Space Grotesk (body), Space Mono (labels). Three CSS-variable surface themes and
~12 lg- keyframes in src/index.css. Pixel sprites in Mascot.jsx render from 7×7 ASCII
maps into SVG.
| Feature | Status |
|---|---|
| Server-authoritative fog | ✅ The map exists only on the server |
| Energy | ✅ Computed server-side, write-through to SQLite |
| Four minigames | ✅ Server validates every input |
| Multiplayer races | ✅ Real players, real settlement window |
| Rival bars | ✅ Real opponents. Shows "you're alone on this one" when they are |
| Anti-cheat | ✅ Timing floors, interval variance, clock-skew checks |
| Fairness proof | ✅ Zone seed commits; salt revealed on resolution |
| Persistence | ✅ SQLite; survives restarts |
| Auth | PlayerRegistry signing ready but needs deployment |
| Prize escrow | ❌ Not built — prizeLabel is a display string |
| Creating hunts | ❌ Needs escrow; the screen says so rather than faking it |
| Leaderboard / profile activity | ❌ Still static arrays |
- Leaderboard and profile activity are static.
BOARD_DATAandPROFILE_FINDSare hardcoded; the server has the data to derive them but doesn't expose it yet. - Fixed-pixel frame. 390 × 844 is hardcoded in
App.jsx, so this is a phone mockup rather than a responsive layout. It needs to be fluid before running in MiniPay's webview. - No real wallet.
src/api/session.jsgenerates a random address in localStorage. Real auth needsPlayerRegistrydeployed and the client signing requests with a bound key.
Done — server-authoritative game, all four minigames, real multiplayer racing, persistence,
rate limiting, metrics, Docker/VPS deployment (server/DEPLOY.md).
Next — deploy PlayerRegistry and switch the client to signed requests; derive the
leaderboard from real results; make the layout responsive.
Then — LootGridEscrow, the chain indexer and the settlement relayer, so prizes actually
pay out. Audit before mainnet.
- No message signing — rules out SIWE, ERC-2612
permitand Permit2. Auth uses a one-time on-chain session-key binding instead. - Legacy transactions only (no EIP-1559), but gas is payable in stablecoins via
feeCurrency— which is what lets a winner claim a prize without holding CELO. - HTTPS required — MiniPay won't load a Mini App over plain HTTP.
Prizes should be escrowed in USDm (18 decimals), USDC or USDT (both 6 decimals) — never CELO, whose volatility would make an advertised dollar prize wrong by the time it's won.