A Web3 first-person tactical shooter built on GoodDollar + Celo. One verified human. One warrior. Forever.
Players verify their identity via GoodDollar, pick a class + callsign, and drop into a solo campaign of first-person doorkicker operations — breach, clear, defend, rescue — across three theatres with escalating bosses. Every kill earns XP toward your rank; clearing an operation for the first time pays a one-time G$ bounty. An endless Kill-House and a ranked, seasonal Gauntlet feed a competitive economy where scarce G$ is earned from competition and spent on gear and survival — with no gas fees required.
Live: https://playvalor.app API: https://valor-qqzx.onrender.com Contracts: Celo Mainnet · Celoscan
The turn-based melee/stat-duel game Valor grew out of is preserved and fully playable at
/fight-legacy— its engine code is untouched.
┌─────────────────────┐ ┌──────────────────────────┐ ┌────────────────┐
│ Next.js 15 App │────▶│ Rust / Actix-web API │────▶│ PostgreSQL │
│ (Vercel) │ │ (Render, Docker) │ │ (Neon) │
└─────────────────────┘ └──────────────────────────┘ └────────────────┘
│ │
│ Magic auth (deterministic │ Celo RPC (forno.celo.org)
│ wallet) + viem │ GoodDollar SDK + Foundry contracts
▼ ▼
┌───────────┐ ┌─────────────────┐
│ GoodDollar│ │ Celo Mainnet │
│ Identity │ │ Smart Contracts│
└───────────┘ └─────────────────┘
decay + reconcile crons → GitHub Actions (every 3h) → POST /decay/run, /battles/bounties/reconcile
| Layer | Stack |
|---|---|
| Frontend | Next.js 15 App Router, Tailwind, Framer Motion, Three.js / React Three Fiber (the FPS scene) |
| Auth | Magic (email + Google → deterministic wallet), viem (no wagmi connector) |
| Backend | Rust, Actix-web 4, SQLx, Tokio |
| Database | PostgreSQL on Neon (permanent free tier). Local dev uses a separate Supabase sandbox. |
| Hosting | Vercel (frontend) + Render (API, Docker) + GitHub Actions (decay/reconcile crons) |
| Chain | Celo Mainnet (chainId 42220) |
| Identity | GoodDollar Citizen SDK (ERC-725 whitelist) |
| Contracts | Foundry (Forge), OpenZeppelin UUPS upgradeable, ERC1155 + ERC677 |
The stack migrated off Railway in July 2026 (trial expired). Money + identity are all on-chain and were safe; ~28 players' off-chain rows were reconstructed from on-chain
ValorGameRecordevents (scripts/reconstruct-players.mjs).
All contracts are UUPS upgradeable proxies, owned by the deployer EOA. Verified on Celoscan.
| Contract | Proxy Address | Purpose |
|---|---|---|
ValorItems |
0x3ba09c51895Dacb90273A2A40C95369a5A1b4bFe |
ERC1155 NFT — one token type per item |
ValorMarketplace |
0x95D167f569cf05C967C0432e3123baeac5D8d78D |
G$ purchases via permit relay; mints item NFTs |
ValorRewardPool |
0x12a3f711A55f4dB0e9AF26C7429cc5018401F1f4 |
Holds G$; pays first-clear bounties + season payouts via distributeReward(player, amount, ref) (idempotent per ref, capped at MAX_REWARD 500 G$) |
ValorGameRecord |
0xd4ec6dB553E206cdf741448F94bD3B02D81c8571 |
Immutable on-chain log of battles, rank-ups, character claims |
| G$ SuperToken | 0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A |
GoodDollar token (ERC20 + EIP-2612 permit) |
Players never need CELO. The pattern:
- Frontend reads the player's current G$ permit nonce on-chain
- Player signs an EIP-2612 permit off-chain (no gas)
- Frontend sends
{ wallet, deadline, v, r, s }to the backend relay - Backend submits the on-chain call, paying CELO gas from the relayer wallet, and records the result
Used for: marketplace purchaseWithPermit, player-to-player transfers (permit + transferFrom), and the Survival re-arm session allowance (one permit per run authorizes many instant re-arms). All signer transactions are serialized behind a nonce lock so concurrent writes can't collide.
A solo, first-person doorkicker campaign: 15 operations across 3 zones with a day → evening → night arc.
- Ashfall (warm day) → Proving Ground (golden evening) → The Rift (moonlit night, NVG earned)
- Bosses on op 5 / 10 / 15 — Cinder, the Warden, Valor — each escalating through 3 phases
- Objective variety, not 15 identical doorkickers:
reach(breach/extract),clear,defend(hold a point while reinforcements trickle in),rescue(a hostage follows you out), plus a Rift blackout op (NVG jammed — fight by muzzle flash) - Weapons + loadout: a 2-weapon loadout with per-weapon feel (sidearm / SMG / assault / marksman / legendary), fire modes (semi/burst/auto), swap
- Attachments (toggleable): NVG, laser, flashlight, optic
- Per-weapon fire audio + per-zone ambience; full VO presence lines; N8AO + set-dressing environment art
- Kill-House (Survival) — an always-open practice arena of escalating waves. No stakes.
- Gauntlet — the ranked, prestige tier, unlocked at campaign completion (
pve_level >= 15). A steeper curve, and its runs are server-validated (a run token + elapsed-time check reject impossible scores) onto the seasonal leaderboard that pays out G$.
Bronze → Silver → Gold → Platinum → Diamond · 1,000 XP per rank.
Every kill earns XP (headshots worth more); the in-game rank bar reflects your real server account (seeded from pve_level/rank, not a local counter). XP is pure progression — ranking up mints no G$ (the old faucet is gone). Rank is recorded on-chain and enrolls you in the rank pool.
Classes (Berserker / Sentinel / Phantom) are the player's chosen identity, set once at onboarding (permanent; callsign is editable). Their stat-duel special abilities live in the preserved legacy game at /fight-legacy.
Principle: XP ≠ G$. XP is infinite progression; G$ is scarce, earned from competition + spent in sinks.
| Flow | Detail | |
|---|---|---|
| In · first-clear bounty (B0) | on-chain | Clearing a Campaign op the first time pays a one-time, capped G$ bounty (ordinary 2 G$; bosses op 5/10/15 pay 10/15/25). Idempotent in DB (first_clear_bounties PK) and on-chain (ref guard). A cron reconciles any failed payout. |
| In · daily UBI | GoodDollar | The Daily Check-In claims GoodDollar UBI (5 G$/day) straight from the protocol. Untouched by the XP change. |
| In · season payout (B3) | on-chain | At season close an admin computes the top Gauntlet runs in the window and distributes a top-heavy split of the prize pool via the RewardPool. |
| Out · marketplace (B1) | on-chain | Buy guns / ammo / attachments / cosmetics with G$, gasless via permit relay. |
| Out · Survival re-arm (B1) | on-chain | Mid-run revive / resupply / wave-skip for G$. One permit per run grants a spending cap (session allowance); re-arms are instant + non-custodial; spent G$ flows into the RewardPool (refilling the prize pool). Disabled in the ranked Gauntlet — pure skill. |
Seasons (seasons table + prize_pool_g): the Gauntlet leaderboard is the flagship metric; GET /seasons/current surfaces the live pool + each rank's estimated payout; the payout job settles winners on-chain at close.
- Node.js 22+
- Rust 1.80+ (
rustup update stable) - Foundry (
curl -L https://foundry.paradigm.xyz | bash && foundryup) - PostgreSQL 15+ (or a Neon / Supabase project) +
psql
git clone https://github.com/MarvyNwaokobia/Valor.git
cd Valor
npm installcd apps/api
cp .env.example .env # fill in DATABASE_URL, BACKEND_PRIVATE_KEY, contracts…
psql "$DATABASE_URL" < migrations/init.sql # then the rest, in order (see Deployment)
cargo run # http://localhost:8080cd apps/web
cp .env.local.example .env.local # fill in NEXT_PUBLIC_* (Magic key, API URL, contracts…)
npm run dev # http://localhost:3000 (the FPS sandbox is /dev/verb)cd contracts
forge build && forge test- Web engine unit tests:
cd apps/web && npx vitest run src/engine - Runtime probes (Playwright vs a dev server):
apps/web/probe-*.mjs - API tests:
cd apps/api && cargo test
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_MAGIC_API_KEY |
✅ | Magic publishable key — email + Google login → deterministic wallet |
NEXT_PUBLIC_API_URL |
✅ | Backend API base URL (the Render URL in prod) |
NEXT_PUBLIC_GOODDOLLAR_ENV |
✅ | production or staging |
NEXT_PUBLIC_MARKETPLACE_CONTRACT |
✅ | ValorMarketplace proxy address |
NEXT_PUBLIC_VALOR_APP_ADDRESS |
✅ | Backend relayer / signer public address |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
— | WalletConnect project id |
NEXT_PUBLIC_RANK_POOL_{SILVER,GOLD,PLATINUM,EMERALD,DIAMOND} |
— | GoodCollective UBI pool addresses for rank enrollment |
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY |
— | Local dev sandbox only |
Runtime URLs (share links, auth redirect, verify callback) are all built from
window.location.origin, so they follow the custom domain automatically — no per-domain config in the frontend.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
✅ | Postgres connection string. In prod: Neon's direct (non--pooler) URL for SQLx. |
BACKEND_PRIVATE_KEY |
✅ | Relayer wallet key — pays CELO gas + is the RewardPool backendSigner |
GAME_RECORD_CONTRACT |
✅ | ValorGameRecord proxy — on-chain battle/rank logging |
MARKETPLACE_CONTRACT |
✅ | ValorMarketplace proxy — gasless purchase relay |
REWARD_POOL_CONTRACT |
✅ | ValorRewardPool proxy — first-clear bounties + season payouts (no code fallback: unset = silent failed payouts) |
ENDLESS_REWARD_POOL_CONTRACT |
— | Separate ValorRewardPool for Endless per-wave payouts, so survival grinding can't starve rank-up/bounty G$. Unset = Endless falls back to REWARD_POOL_CONTRACT |
ENDLESS_MIN_SECS_PER_WAVE |
— | Min real seconds per Endless wave (default 6) — the anti-script timing floor |
ENDLESS_WEEKLY_CAP_G |
— | Per-player weekly Endless G$ cap (default 0 = no cap) |
ENDLESS_POOL_WARN_G |
— | Log a low-balance warning when the Endless pool drops below this (default 10000) |
CELO_RPC_URL |
✅ | Celo RPC (default https://forno.celo.org) |
VALOR_APP_ADDRESS |
✅ | Public address of BACKEND_PRIVATE_KEY |
DECAY_CRON_SECRET |
✅ | Shared x-cron-secret for /decay/run + /battles/bounties/reconcile |
FRONTEND_ORIGIN |
✅ | Comma-separated CORS origins (https://playvalor.app,https://playvalor.vercel.app) |
BIND_ADDR |
— | Bind address (Render sets 0.0.0.0:10000; default 0.0.0.0:8080) |
RANK_POOL_{SILVER,GOLD,PLATINUM,EMERALD,DIAMOND} |
— | GoodCollective UBI pool addresses |
G_TOKEN_CONTRACT |
— | G$ SuperToken (defaults to the known mainnet address) |
ADMIN_WALLETS |
— | Comma-separated wallets allowed to sign into /admin |
ADMIN_JWT_SECRET |
— | Signing secret for admin session tokens |
RUST_LOG |
— | Log level (default info) |
| Variable | Required | Description |
|---|---|---|
DEPLOYER_PRIVATE_KEY |
Deploy only | Wallet with CELO for gas (prefixed 0x) |
BACKEND_SIGNER_ADDRESS |
Deploy only | Address of BACKEND_PRIVATE_KEY |
REWARD_POOL_CONTRACT |
Upgrade | Proxy address for upgrade scripts |
CELO_RPC_URL / CELOSCAN_API_KEY |
Deploy only | RPC + Celoscan verification |
| Method | Path | Description |
|---|---|---|
POST |
/players |
Create player (username + class, auto-confirmed for new wallets) |
GET |
/players · /players/search?q= · /players/:wallet |
List / search / get |
PATCH |
/players/:wallet |
Update callsign / class / customization / confirm flag |
GET/PATCH |
/players/:wallet/inventory[/:itemId] |
Inventory + equip toggle |
GET |
/players/:wallet/battles · /achievements · /daily-claim-status |
Reads |
POST |
/players/:wallet/daily-claim · /decay-check · /freeze-decay |
Actions |
GET |
/players/:wallet/ledger-summary |
G$ earned/spent breakdown (Bank page) |
POST |
/players/:wallet/transfer |
Transfer G$ to any wallet (player-signed permit, relayed) |
GET |
/relay-address |
Backend relay wallet address (the permit spender) |
| Method | Path | Description |
|---|---|---|
POST |
/battles/fight/complete |
Finalize a fight/op — server XP → rank; first clear advances pve_level + pays the bounty |
POST |
/battles/pvp/complete · /battles/bot/* · /battles/challenge |
PvP / bot / async challenge |
POST |
/battles/bounties/reconcile |
Cron: re-attempt failed first-clear bounties (idempotent) |
POST |
/survival/arm |
Grant a per-run G$ spending allowance (one signed permit) |
POST |
/survival/rearm |
Spend a re-arm (revive / restock / waveskip) against the allowance |
POST |
/gauntlet/start · /gauntlet/submit |
Issue a run token / submit a validated run |
GET |
/gauntlet/leaderboard?scope=weekly |
Best-per-wallet Gauntlet board |
GET |
/seasons/current |
Live season: prize pool + windowed leaderboard + est payout per rank |
GET/POST |
/endless/leaderboard · /endless/score |
Casual endless board |
| Method | Path | Description |
|---|---|---|
GET |
/items |
Marketplace items |
POST |
/items/:id/purchase-relay |
Gasless purchase (EIP-2612 permit relay) |
POST |
/rewards/sign-claim · GET /identity/verify/:wallet |
GoodDollar AppClaim signing / identity status |
POST |
/decay/run |
Decay sweep (cron, x-cron-secret) · GET /health |
POST |
/admin/login |
Wallet-signature login → short-lived admin JWT (ADMIN_WALLETS) |
GET/POST |
/admin/seasons[...] |
List / create / :id/end / :id/fund {prize_pool_g} / :id/payout (all admin-bearer) |
WS |
/ws/battle |
Live PvP battle channel |
Auto-deploys on push to main. Set all NEXT_PUBLIC_* in the Vercel dashboard. Custom domain playvalor.app is primary (playvalor.vercel.app 308-redirects to it). New domains must be added to Render FRONTEND_ORIGIN (CORS) and the Magic allowlist (origins + /auth/callback).
A Render Web Service builds apps/api/Dockerfile and auto-deploys on push. Health check /health, BIND_ADDR=0.0.0.0:10000. Free tier spins down after ~15 min idle (≈50 s cold start). Env vars per the table above (ported from Railway; FRONTEND_ORIGIN + DATABASE_URL are the ones that change per environment).
Apply migrations in order via scripts/bootstrap-neon.mjs (run from repo root after npm i pg --no-save, with DATABASE_URL set):
init.sql → 004_guns → 005_pve_level → 006_endless → 007_remove_melee_items →
008_ammo_attachments → add_chain_tx_columns → fix_decimal_columns →
add_gdollar_ledger → add_first_clear_bounties → add_character_confirmed →
add_survival_rearms → add_survival_runs → add_season_payouts
scripts/reconstruct-players.mjs rebuilds players rows from on-chain ValorGameRecord events if off-chain data is ever lost.
.github/workflows/decay-cron.yml runs every 3h, hitting POST /decay/run and POST /battles/bounties/reconcile with x-cron-secret. Requires repo secrets API_URL + DECAY_CRON_SECRET.
cd contracts && source .env
forge script script/Deploy.s.sol --rpc-url $CELO_RPC_URL --broadcast --verify --etherscan-api-key $CELOSCAN_API_KEY
forge script script/Setup.s.sol --rpc-url $CELO_RPC_URL --broadcast # register + list items
forge script script/UpgradeRewardPool.s.sol --rpc-url $CELO_RPC_URL --broadcast --verify # adds distributeRewardNew items must be registered on-chain (ValorItems + ValorMarketplace) before they appear in the UI. Fund ValorRewardPool with G$ so bounties + payouts can pay.
Valor/
├── apps/
│ ├── web/ # Next.js 15 frontend (Vercel)
│ │ └── src/
│ │ ├── app/ # App Router (fight/, dev/verb sandbox, auth/callback, admin, bank…)
│ │ ├── engine/
│ │ │ ├── fps/ # Headless FPS sim: FpsSim, campaign (15 ops + survival + gauntlet), xp
│ │ │ ├── scene/ # the R3F game scene, operator rigs, set dressing
│ │ │ ├── audio/ # FpsAudio director
│ │ │ └── story/ # VO presence lines
│ │ ├── components/ # battle/ (OperationsSelect…), marketplace/, player-card/, ui/, providers/
│ │ ├── hooks/ # useSurvivalRearm, useGauntlet, useFightRewards, useMarketplace, useTransferOut…
│ │ ├── stores/ lib/ types/
│ └── api/ # Rust / Actix-web backend (Render)
│ └── src/handlers/ # players, battles, survival, gauntlet, seasons, items, admin, decay, ledger…
│ └── migrations/ # SQL migrations
├── contracts/ # Foundry: ValorItems / Marketplace / RewardPool / GameRecord
├── docs/ # B0_ECONOMY_DEPLOY, C5_SHIP_GATE, GAME_DESIGN…
└── scripts/ # bootstrap-neon.mjs, reconstruct-players.mjs, generate-vo.mjs…
MIT