Discord Activity Integration — Design Spec
Date: 2026-04-08 (updated 2026-04-09)
Target: examples/test-player-map/
Status: Working in production
Context
examples/test-player-map/ is a Vite + React + TypeScript app that consumes the player-map npm package. It uses Privy (@privy-io/react-auth) as its sole authentication layer. The goal is to add Discord Activity support so the app can run:
- In standalone mode (browser, unchanged behavior)
- In Discord Activity mode (iframe inside a voice/text channel)
Architecture
Mode Detection
URL contains frame_id + instance_id → Discord Activity mode
URL contains ?session=TOKEN → External wallet login page
Otherwise → Standalone mode
isDiscordActivity() in src/utils/discordEnv.ts performs the Discord check.
Provider Tree
Discord Activity mode:
main.tsx
└─ [fetch monkey-patch: redirect external API calls through /.proxy/]
└─ DiscordProvider (Discord SDK init, OAuth, context)
└─ App
└─ PrivyWalletProvider
└─ PlayerMapView (Discord wallet flow)
External wallet login page (?session=TOKEN):
main.tsx
└─ App
└─ PrivyWalletProvider
└─ WalletLoginPage (Privy login → POST /session)
Standalone mode (unchanged):
main.tsx
└─ App
└─ PrivyWalletProvider
└─ PlayerMapView (Privy wallet flow)
Wallet Connection Flow (Discord mode)
Discord's iframe blocks Privy's popup-based login. Wallet connection uses an external page:
1. User clicks "Connect Wallet" in Discord iframe
2. UUID token generated → stored in localStorage
3. Iframe fetches /.proxy/external-url → gets EXTERNAL_URL (Cloudflare tunnel URL)
4. QR code + "Open in browser" shows: {EXTERNAL_URL}/?session={token}
5. User opens URL in browser → WalletLoginPage renders
6. User logs in with Privy → wallet address obtained
7. POST /session { token, address } → server stores it (in-memory, 10min TTL)
8. Discord iframe polls /.proxy/session/:token every 2s
9. Server returns { address } → iframe sets discordAddress → renders map
CSP Constraint (Critical)
Discord's iframe enforces a strict CSP: only connections to *.discordsays.com are allowed. This blocks:
https://mainnet.intuition.sh/v1/graphql(Intuition GraphQL API)https://rpc.intuition.systems(Ethereum RPC)wss://relay.walletconnect.org(WalletConnect — not used in Discord mode)
Fix: Monkey-patch window.fetch in main.tsx before React renders, redirecting blocked URLs through /.proxy/:
https://mainnet.intuition.sh/v1/graphql → /.proxy/graphql
https://rpc.intuition.systems → /.proxy/rpc
The server (unified-server.ts) proxies these to the real endpoints.
Files
New Files
| File | Purpose |
|---|---|
src/utils/discordEnv.ts | isDiscordActivity() detection |
src/providers/DiscordProvider.tsx | Discord SDK init, OAuth flow, React context |
src/components/DiscordBadge.tsx | Visual badge (Discord-only) |
src/components/WalletLoginPage.tsx | External wallet login page (Privy → POST /session) |
api/server.ts | Express: OAuth token exchange + session store + proxy routes (dev mode) |
api/unified-server.ts | Express: serves dist/ + all API routes (production mode) |
Modified Files
| File | Change |
|---|---|
src/main.tsx | Conditional DiscordProvider + fetch monkey-patch for CSP bypass |
src/App.tsx | Routes to WalletLoginPage when ?session=TOKEN, adds DiscordBadge |
src/components/PlayerMapView.tsx | Full Discord wallet flow (session polling, discordWalletProxy, discordPublicClient) |
vite.config.js | Proxy ALL /.proxy/* → http://localhost:3001 (was only /.proxy/api) |
package.json | Added deps + dev:api and serve:discord scripts |
Server Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /token | Discord OAuth code → access_token |
| POST | /session | Store { token, address } (10min TTL) |
| GET | /session/:token | Retrieve address for token |
| GET | /external-url | Returns EXTERNAL_URL env var |
* | /graphql | Proxy → https://mainnet.intuition.sh/v1/graphql |
| POST | /rpc | Proxy → https://rpc.intuition.systems |
PlayerMapView Discord Specifics
discordPublicClient: Static viem client, transport/.proxy/rpc,getChainId()hardcoded to1155(bypasses wagmi state — no active connector in Discord mode)discordWalletProxy: Minimal wallet client object fromdiscordAddress;writeContractandsignTypedDatathrow (transactions require browser signing)discordUseAccount: Mock wagmiuseAccounthook returningchain: intuitionMainnet,isConnected: true
Environment Variables
Client-side (.env)
VITE_PRIVY_APP_ID=
VITE_DISCORD_CLIENT_ID=
VITE_WALLETCONNECT_PROJECT_ID=
Server-side (process env / shell)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
EXTERNAL_URL=https://xxx.trycloudflare.com # Cloudflare tunnel URL
PORT=5173 # unified-server only
Dev Workflow
Mode dev (Vite + API séparés) :
# Terminal 1
EXTERNAL_URL=https://xxx.trycloudflare.com npm run dev:api
# Terminal 2
npm run dev
# Terminal 3
cloudflared tunnel --url http://localhost:5173
Mode production (recommandé pour Discord Activity) :
npm run build
EXTERNAL_URL=https://xxx.trycloudflare.com npm run serve:discord
# + cloudflared tunnel --url http://localhost:5173
Constraints
- Standalone mode works exactly as before — no regression
- All Discord code conditional on
isDiscordActivity()or?session=param DISCORD_CLIENT_SECRETstays server-side only- Transactions not supported in Discord (proxy wallet throws) — read-only mode only
EXTERNAL_URLmust be set at server startup; it changes with each Cloudflare tunnel run
Known Limitations
- In-memory session store → sessions lost on server restart
- Cloudflare tunnel URL changes each run → must update Discord Activity URL mapping in portal
- WalletConnect blocked by CSP (not a problem — Privy is used instead on external page)
- Discord Activity URL mapping in portal must point to current Cloudflare tunnel URL