Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 372 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
# CLAUDE.md

Guidance for Claude Code (and any AI assistant) working in this repository.

NULL_STATE is a real-time, top-down dungeon crawler on Celo, built to run inside
MiniPay. Playing is free; only Marketplace/Season Pass purchases settle on-chain,
and USDT rewards are paid out from the Treasure Vault, Leaderboard and Season
Pass. Next.js 14 App Router + React 18 + TypeScript on the outside, a vanilla
Canvas2D engine on the inside.

---

## 1. The one thing to understand first: two worlds, one boundary

This repo contains **two codebases that cannot import from each other.**

| | The shell | The engine |
|---|---|---|
| Where | `app/`, `components/`, `lib/`, `hooks/` | `public/game-engine/*.js` |
| What | Next.js App Router, React, TypeScript | 14 plain `<script>` files, vanilla JS + Canvas2D |
| Built by | `next build` — typechecked, bundled | Nothing. Served byte-for-byte out of `public/` |
| Entry point | `components/game/DungeonGame.tsx` | `window.NullStateGame.{mount,unmount}` |

**The engine cannot `import` from `lib/`.** It is loaded by `<script>` tag, so
there are no modules and no bundler. Values cross the boundary only through:

- `window.__NS` (engine-owned state read by the shell — see `game.js`)
- `mount(opts)` options passed in by `DungeonGame.tsx`
- an API call to `app/api/**`

Consequences you must respect:

- A constant that must exist on both sides is **duplicated on purpose**, and the
engine's copy is authoritative for gameplay. The `lib/` copy must say so in a
comment. Today this applies to the marketplace catalogue
(`lib/constants/marketplace.ts` ↔ `public/game-engine/marketplace-items.js`),
guarded by `npm run check:market`.
- A syntax error in an engine file **passes the build and ships**. CI runs
`node --check` over every engine file for exactly this reason.
- Engine load order matters (`assets.js` must run before `entities.js`). The list
lives in `DungeonGame.tsx:34` (`ENGINE_SCRIPTS`); `lib/engineScript.ts` owns
which copy is served and the cache-busting `?v=<build id>`.
- Production serves the **minified** copies from `public/game-engine/min/`
(gitignored build output, regenerated by `npm run build:engine`). Dev serves the
readable sources so stack traces point at findable lines.

---

## 2. Commands

```bash
npm ci # install exactly what package-lock.json pins
npm run dev # dev server on http://localhost:3000
npm run build # minifies the engine, then next build
npm start # serve the production build
npm run lint # ESLint (NOT run during builds — see below)
npx tsc --noEmit # type check — this is what CI enforces
```

### Verification scripts (`scripts/`)

These are not a test framework — they are single-purpose Node scripts, each
written to catch one class of bug that had already shipped. Read the header
comment of a script before changing it; it explains the failure it exists for.

**Consistency checks (all gated in CI):**

| Command | Guards |
|---|---|
| `npm run check:market` | the two marketplace price lists agreeing |
| `npm run audit` | every asset/sprite/script path resolving to a real file |
| `npm run check:copy` | MiniPay's copy rules (banned words, connect prompts, signing) |
| `npm run check:attribution` | every Celo tx carrying its ERC-8021 tag |
| `npm run check:cssvars` | no stylesheet using a `var(--x)` another sheet declares |
| `npm run build:icons` / `build:weapons` | generated art matching its masters |

**Behaviour tests** — `test:season`, `test:seasonxp`, `test:kills`, `test:play`,
`test:contracts`, `test:streak`, `test:migrate`, `test:vaultwin`,
`test:vaultpay`, and ~20 more
(see `package.json` scripts). Some drive a real browser via `playwright-core`
against engine files loaded directly; those taking `--serve` (`test:privy`,
`test:season-ui`, `test:campaign-end`, `test:pass-cards`) spawn `next start`
themselves and therefore need `npm run build` first.

Chromium is preinstalled in the remote environment — never run
`playwright install`.

`npm run test:play` is the integration test: it actually plays a run (walks the
map, fights, loots, rides the lift, burns, saves). Run it before anything that
touches the engine goes out.

### What CI runs

`.github/workflows/checks.yml` on every PR: `npm ci`, the six consistency checks
above, `node --check` over engine sources and minified output, and `npx tsc
--noEmit`. It deliberately does **not** run `next build` (Vercel already does)
and deliberately does **not** run `npm run lint` — the repo carries ~20 files of
pre-existing stylistic findings, and a permanently-red check trains people to
ignore CI. `next.config.js` sets `eslint.ignoreDuringBuilds` for the same reason.

---

## 3. Layout

```
app/
layout.tsx root layout — NO Web3Providers here, and no preconnects
(both have been tried and measured; see the comments)
page.tsx landing
game/ profile/ routes that wrap children in Web3Providers via a nested layout
stats/ docs/ privacy/ terms/
api/ 45 route handlers, one directory per capability
marketplace/verify on-chain purchase verification (the reference route)
vault/prepare stores the week's vault code on chain BEFORE anyone wins
cron/season Vercel cron, daily 01:00 UTC — season close/payout, and
the sweep that retries unpaid vault rewards
components/
game/ the shell's screens: GameFlowManager (router), WorldMapHub,
DungeonGame (engine host), MarketplaceScreen, RewardsScreen,
SeasonPassScreen, CraftingScreen, Leaderboard, PrivyGate…
ui/ landing/ common/
lib/
constants/ game-config, marketplace, tokens, bunkers, seasonRewards
server/ server-only logic: seasonClose, loginStreak, dailyContracts,
energy, vault-fragments, referrals, guestMigrate,
vaultChain (all on-chain vault work), vaultSweep…
WagmiIsland.tsx wagmi/viem, mounted as a deferred SIBLING (see §5)
WalletProvider.tsx useWallet() — the identity surface
celoRpc.ts the ONLY place a Celo endpoint may be named
siteUrl.ts the ONLY place the canonical origin may be named
hooks/ usePassSBT, useReward, useVault (wagmi-backed contract hooks)
contracts/ PassSBTv3.sol, NullStateRewardV3.sol, TreasureVaultV2.sol
public/game-engine/ THE GAME (see §1)
public/sprites/ LPC-derived character/monster/weapon sheets
styles/ globals.css (always loaded) + per-route sheets
scripts/ verification + asset-generation scripts (see §2)
docs/ design and operations docs (see §8)
__tests__/ two legacy unit tests; new coverage goes in scripts/
```

---

## 4. Invariants (from `docs/GAME-DESIGN.md` §10)

Breaking one of these is how the game drifted the first time.

1. **The engine cannot import from `lib/`.** See §1.
2. **Config that nothing reads is a lie.** Before adding to
`lib/constants/game-config.ts`, confirm a line of source will import it. Nine
blocks were deleted in one pass because they were unread *and wrong*, and
their wrong numbers had already reached the player-facing docs.
3. **Money is weekly. Progress is daily.** Do not add USDT payouts to the daily
layer.
4. **Never make a real reward reachable only by luck.** Luck may accelerate it;
effort must guarantee it.
5. **Sessions stay short.** If a change makes the minimum useful session longer
than ~5 minutes, it is wrong for MiniPay.
6. **Content does not fix a missing loop.** More bunkers/monsters/weapons will
not answer "why come back tomorrow."
7. **Update `docs/GAME-DESIGN.md` in the same PR.** Flip `[TARGET]` → `[TODAY]`
when you ship it.

Two more that the tooling enforces:

8. **Single-source config.** `lib/celoRpc.ts` owns RPC endpoints, `lib/siteUrl.ts`
owns the origin. `npm run audit` fails if any other file names an endpoint —
they used to disagree, and the knob only half-worked.
9. **Every Celo transaction carries its ERC-8021 attribution tag** — via
`dataSuffix:` or `withAttribution()` on `data`. This *cannot be backfilled*;
an untagged tx is unattributed forever. `npm run check:attribution` gates it,
and this includes the four transactions the **backend** signs.

---

## 5. Web3 and wallet conventions

- **Chain:** Celo Mainnet (`42220`). Tokens: Mento USDM, USDC, USDT — mixed 6-
and 18-decimal, always go through `parseTokenAmount` in `lib/constants/tokens.ts`.
- **Connectors:** injected only. MiniPay auto-connects silently; MetaMask
extension works. WalletConnect / Coinbase / Rainbow are **not** integrated and
the relay was deliberately removed — do not reintroduce them.
- **No `personal_sign` / `eth_signTypedData`.** MiniPay does not support message
signing at all; a call is a hard failure on a real device. `check:copy` enforces it.
- **wagmi loads late, and as a sibling.** `lib/Web3Providers.tsx` renders children
immediately behind a dependency-free bridge, then mounts `WagmiIsland` beside
them on idle (1500 ms ceiling). The obvious `ready ? <WagmiProvider>{children}`
form would remount the whole tree the moment wagmi arrived. Never "simplify"
this into a wrapper.
- **Three identity tiers**, most-authoritative first (`useWallet()` in
`lib/WalletProvider.tsx`):
- `realAddress` — a wallet. The only tier that can sign, pay or claim.
- `authAddress` — a Firebase account rendered as an address (SHA-256 of the uid,
first 20 bytes; `lib/authIdentity.ts`). Stable across devices, cannot sign.
- `guestAddress` — random localStorage id. Cannot sign, dies with the browser.

All three are `/^0x[0-9a-f]{40}$/`, so every Firebase-keyed path works
unchanged. **None of them is a credential** — anything that must be protected
is protected by Firestore rules keyed on the authenticated uid, never by the
unguessability of an address.
- **Feature flags** live in one file each and are read by every consumer:
`lib/worldMapHubFlag.ts` (default **on**; `NEXT_PUBLIC_WORLDMAP_HUB=0` reverts
to the classic menu) and `lib/privyGateFlag.ts` (default **off**, never inside
MiniPay, requires an app id; temporary scaffolding for talent.app judging).

---

## 6. Server routes and data

- Route handlers live at `app/api/<capability>/<action>/route.ts` and return
`NextResponse.json`. Business logic that is worth testing belongs in
`lib/server/` so a script can bundle and exercise it (`test:season` does this
with esbuild).
- **Firebase Admin** is initialised in `firebase-config.ts` via `getAdminDb()`.
Never import it from client code — client Firebase is `lib/firebase.ts`.
`firebase-admin` v14 has no namespaced API; use the subpath modules
(`firebase-admin/app`, `/database`, `/auth`). It is also pinned as a
`serverComponentsExternalPackages` external in `next.config.js` to avoid
`ERR_REQUIRE_ESM` in the serverless bundle.
- **Storage split:** Realtime DB (asia-southeast1) holds player profiles,
marketplace ownership and materials; Firestore holds usernames, bunker saves
and the leaderboard.
- **Every on-chain claim is verified server-side**, and every verification is
replay-protected by recording the tx hash before granting anything — see
`app/api/marketplace/verify/route.ts`, which is the pattern to copy. Use
`waitForTransactionReceipt` (not `getTransactionReceipt`); the frontend calls
these routes immediately after broadcasting, so the tx is usually not indexed yet.
- Firestore transactions must **read before write**. A violation of this rule
silently discarded every kill for a month; `test:kills` and `test:seasonxp`
assert it for every transaction in their modules.
- **Never put two on-chain writes in one player request.** A vault payout did —
store the week's code, then pay — and only for the *first* winner of each
week, so it looked random and survived weeks. Forno is load-balanced, so the
node answering the nonce read for the second write need not have seen the
first; the transaction was rejected at the RPC boundary before it was ever a
transaction, and the player was told the reward pool was empty. Do the
prerequisite write on its own schedule (`/api/vault/prepare`, plus the cron),
and if two writes genuinely must be sequential, read the nonce **once** and
increment locally. `lib/server/vaultChain.ts` carries the chain evidence.
- **Money that fails must retry without the player.** The vault's only recovery
was re-opening the vault door, which means re-clearing Bunker 5 and killing
the final boss. Anything that can leave a reward unpaid needs a server-side
sweep — `lib/server/vaultSweep.ts`, run from the daily cron.
- **Never name a cause you did not check.** Every failed vault payout printed
"the transfer completes as soon as the reward pool is topped up" and the
server had never read the pool; it cost hours of looking in the wrong place.
Read the balance before blaming the balance (`VAULT_FAILURE_MESSAGE`).
- Vercel: API functions get `maxDuration: 30`; `/api/cron/season` runs daily at
01:00 UTC (`vercel.json`).

---

## 7. MiniPay listing rules that live in code

These are not style preferences — the listing review rejects the app over them.

- **Banned player-facing words:** "gas" → *network fee*, "onramp"/"offramp" →
*deposit*/*withdraw*, "crypto" → *stablecoin*, and no "Add Cash" button label.
Code identifiers, comments and `eth_gasPrice` are fine; `check:copy` only reads
text a user can see.
- **No "Connect wallet" copy and no connect button.** Inside MiniPay the address
resolves with zero interaction, so a connect prompt is always wrong. No gate
screen may appear between a MiniPay player and the game — not even for a frame.
- **`.npmrc` is a listing requirement:** `minimum-release-age=10080` (reject
dependency versions published in the last 7 days) and `ignore-scripts=true`.
Do not remove either; install with `npm ci`/`npm install` as-is.
- Load speed is graded on real-user p75, which is why the code-splitting in §5
and the "no preconnects" note in `app/layout.tsx` exist. Both were measured.
Read the comments before undoing either.

---

## 8. Documentation map

`docs/GAME-DESIGN.md` is the source of truth for what the game *is* — read it
before changing any loop or economy value. It carries `[TODAY]`/`[TARGET]`
status markers, a decision log, and the invariants in §4.

| File | Covers |
|---|---|
| `docs/GAME-DESIGN.md` | the loops, the four time layers, why each system exists |
| `docs/game-mechanics.md`, `docs/faq.md` | player-facing rules |
| `docs/rewards-system.md`, `docs/leaderboard.md`, `docs/pass-system.md` | economy |
| `docs/TREASURY-OPS.md`, `docs/OWNER-RUNBOOK.md` | operating the treasury, deposit log |
| `docs/network-manifest.md` | every external endpoint the app talks to |
| `ARCHITECTURE_HYBRID.md`, `NULLSTATE_WEB3_ARCHITECTURE.md` | system architecture |
| `MINIPAY-COMPLIANCE-CHECKLIST.md` | the full listing checklist |
| `DEPLOYMENT.md`, `FIREBASE_SETUP.md`, `SEASONS_CONFIGURATION.md` | ops setup |

Player-visible numbers appear in `docs/` **and** in the app's How to Play screen.
If you change an economy value, change both — wrong numbers reaching the docs is
the specific failure invariant #2 was written for.

---

## 9. House style

**Comments explain the failure, not the code.** This repo's defining convention
is that non-obvious code carries a header comment naming the bug it exists for,
what was tried before, and why the obvious alternative is wrong. Read those
before editing — several of them exist because a previous change was reverted.
When you fix something subtle, leave the same kind of note; when you change code
that has one, keep it accurate.

**Commits are narrative.** The subject line is a sentence describing the problem
in the player's or owner's terms, not a conventional-commits prefix:

```
The season score punished the players who had been here longest (#233)
A Google player can pay, and no MiniPay player pays for it (#232)
The half second of map that should not have been there (#226)
```

The body states the symptom (often quoting the owner), the mechanism, the fix and
its shape, migration impact if any, and a **Verified:** line listing what was run
(`19/19 test:seasonxp, 8/8 test:kills, tsc clean, build 143/242 kB, lint 35`).
Match this. Run the relevant checks and report the real numbers.

**Other conventions:**
- Imports use the `@/*` path alias (`tsconfig.json`).
- `strict: true`. `npx tsc --noEmit` must be clean — it is the check CI enforces.
- Client components need `'use client'`; the wallet layer depends on precise
client/server boundaries.
- Server-only env vars must never gain a `NEXT_PUBLIC_` prefix
(`POSTHOG_PERSONAL_API_KEY`, `CELO_RPC_URL`, `BACKEND_PRIVATE_KEY`,
`FIREBASE_SERVICE_ACCOUNT`, `DEV_TEST_WALLETS`).

---

## 10. Environment

Required for a functioning backend (not in `.env.example`, which documents only
the optional knobs):

```
FIREBASE_SERVICE_ACCOUNT # service-account JSON string (never commit)
FIREBASE_DATABASE_URL # regional RTDB URL (asia-southeast1)
BACKEND_PRIVATE_KEY # backend signer: vault payouts, pass mints
NEXT_PUBLIC_FIREBASE_* # client SDK config (lib/firebase.ts)
```

Note the name is `FIREBASE_SERVICE_ACCOUNT` — `firebase-config.ts` reads exactly
that, and warns and disables Admin rather than throwing when it is absent.

`.env.example` documents everything else with the reasoning for each, and all of
it is inert when unset: PostHog (Web Vitals reporting and the `/stats` speed
card), `NEXT_PUBLIC_CELO_RPC` / `CELO_RPC_URL` (defaults to Forno with a viem
`fallback()` chain), `NEXT_PUBLIC_SITE_URL`,
`NEXT_PUBLIC_CELO_ATTRIBUTION_CODE`, the feature flags in §5, and two plain
random strings that are not keys and cannot spend anything — `CRON_SECRET`
(authenticates Vercel's call to `/api/cron/season`) and `ADMIN_SECRET` (sent as
`x-admin-secret` to mark a season paid; it only sets a flag).

`DEV_TEST_WALLETS` is a server-side allowlist that unlocks marketplace items
without payment for on-device QA — leave it unset in production; grants are
tagged `devGrant:true` in Firebase so they can be found and wiped.

Contract addresses have hardcoded mainnet fallbacks in `lib/contract-abi.ts` and
only need overriding for a redeploy.

---

## 11. Session notes

- `.claude/hooks/session-start.sh` runs on session start: sets the repo-local git
identity (commits are the owner's; Claude stays on the `Co-Authored-By`
trailer), installs `node_modules` if missing, and regenerates
`public/game-engine/min/` so anything serving the app without a full build
measures the right thing.
- `node_modules/` and `public/game-engine/min/` are gitignored — never commit them.
- Before opening a PR, at minimum: `npx tsc --noEmit`, plus the checks relevant to
what you touched. Engine changes → `npm run test:play`. Economy or season logic
→ the matching `test:*` script. Assets → `npm run audit`.
Loading