Battle Arena Integration
Passive pixel art mascot battle game for fundraiser team profile pages with real-time sales-driven gameplay
Fundraiser Battle Arena — Integration Guide
Overview
The Battle Arena is a passive, sale-driven pixel art mascot battle game. Each team's
fundraiser page (/fundraise/[slug]) shows a compact battle widget, and the full arena
for a month lives at /arena/[period] (period is YYYY-MM) in the fundraiser portal.
Every sale for a team triggers an attack. Sharing the page activates a 30-minute shield. No manual gameplay exists — the game reflects real fundraising activity.
Where the code lives
| Piece | Path |
|---|---|
| Full arena UI | app/(fundraiser-portal)/arena/[period]/arena-client.tsx: the 3D game's Play card (components/arena/battle-arena-game.tsx) above the live standings (components/arena/leaderboard-table.tsx) |
| Page widget | components/arena/battle-widget.tsx (rendered by app/fundraise/[slug]/page.tsx) |
| Client polling | lib/arena/use-arena-state.ts |
| Snapshot builder | lib/arena/server-state.ts |
| Damage | lib/arena/damage.ts (applyPurchaseDamage) |
| Shields | lib/arena/shield-service.ts, lib/arena/share-nonce.ts |
| Constants | lib/arena/rules.ts (SHIELD_DURATION_MS, SHIELD_MAX_HP) |
| Standings | lib/arena/standings.ts (getBattleStandings) |
API routes
| Route | Purpose |
|---|---|
GET /api/fundraiser/arena/[period]/state | Public arena snapshot (HP, shields, feed). Rate-limited to 120 req/min per IP. |
GET/POST /api/fundraiser/arena/[period]/messages | Arena chat feed (no page posts to it since the 3D game replaced the 2D arena) |
POST /api/fundraiser/arena/share/intent | Mints a single-use share nonce; redeeming /s/[nonce] activates the shield |
POST /api/fundraiser/sale | Machine-to-machine sale ingest (HMAC API key, idempotent on orderId) |
POST /api/fundraiser/sale-events/[id]/love, .../reply | Reactions and replies on feed events |
/api/admin/arena/seasons/... | Season management: create, add/remove teams, reset HP, end season |
Shield HP is stored on FundraiserShield.remainingHP (default 30) and decremented as
opponent damage is absorbed. Share rate limits (enforced at redemption): at most one
shield per user per platform per hour, five per user per day, 20 per team per day, and the
user account must be at least 24 hours old.
Where a sale comes from
An arena team is the gamification layer over a Fundraiser, not a campaign of its own:
FundraiserTeam.fundraiserId is required and unique. The campaign owns the store —
the catalogue, the price per jar and the split — and the team owns the battle.
That means a purchase in the arena is an ordinary order. It goes through the normal
cart and /api/checkout, so it is charged the campaign's price, collects shipping and
tax, reserves inventory, credits the group its share, and lands on the campaign's
thank-you page like any other fundraiser sale.
Damage is dealt by the arena-damage consumer in
lib/domain-events/handlers/arena-damage.ts, off payment.completed — so it fires from
whichever of the payment paths settled the order rather than from one provider's webhook.
It is sized on merchandise (subtotal less discounts), the same base commission is taken
from: letting freight or tax swing a battle would mean a supporter in a high-tax state hit
harder for the same salsa. FundraiserSaleEvent.orderId is unique, so a replayed event is
an idempotent hit rather than a second strike.
The team shop used to build its own Stripe Checkout Session from a parallel
FundraiserTeamProduct catalogue and wrote no order at all — no shipping, no tax, no
inventory movement, no commission, no order history — while pricing itself from the retail
catalogue. Both that table and that route are gone. There is one catalogue
(FundraiserProduct) and one checkout.
Donations are still their own thing. /api/fundraiser/donate/create-session buys no
goods, so it has no order to hang off and still settles through the Stripe metadata path in
app/api/webhooks/stripe.
Which sales count, and who wins
Only a sale placed during the team's current battle deals damage. applyPurchaseDamage
takes the order's own placedAt and refuses anything placed before the team joined or outside
the battle window (the season's startsAt–endsAt, else the period month). Past orders that
arrive late — a history import, the BigCommerce mirror catching up, a late payment — record
nothing and damage nobody.
Each sale event stores the battle period it struck. Standings come from those events, not
from FundraiserTeam.salesCount (a lifetime counter that survives across seasons):
getBattleStandings in lib/arena/standings.ts ranks the season's own roster (or, with no
season, the period's ACTIVE teams) by sales in that battle, earliest team first on a tie. The
public /battles board and the season-end route (POST /api/admin/arena/seasons/[id]/end)
both use it, so the leaderboard always shows the team the season will crown. A season that
ends with no sales crowns nobody.
Browser game fundraiser codes
The 3D Battle Arena browser game (a separate project) lets only enrolled fundraising groups
fight online and in tournaments. An admin makes one code per group at
Admin → Fundraisers → Battle Arena → Game codes (/admin/fundraisers/battle-arena/game-codes),
optionally linked to a Fundraiser, and hands it to the group's players. Codes are
ArenaGameCode rows formatted JM-XXXX-XXXX (lib/arena/game-codes.ts), so the same code
works on every device.
The game checks a typed code with the public POST /api/arena/game-codes/verify
({ code } → { valid: true, name } or { valid: false }). It answers any origin (the game is
served from its own domain), accepts the code however it is typed, and is limited to 30 checks
a minute per IP so codes cannot be guessed. Revoking a code (PATCH /api/admin/arena/game-codes/[id]) stops new players using it and keeps the row.
Game Mechanics Reference
| Event | Result |
|---|---|
| $45 sale on MY page | MY mascot attacks for 45 damage |
| $100 sale on OPP page | OPP mascot attacks for 100 damage |
| Shield active, OPP $100 sale | Shield absorbs up to $30, remaining 70 hits HP |
| Shield active, OPP $20 sale | Shield absorbs all 20, mascot HP unchanged |
| Shield at 0 HP remaining | Shield inactive, full damage hits mascot HP |
| Facebook share (no shield) | 30-min shield activates, absorbs up to $30 |
| Facebook share (shielded) | No effect |
| Mascot HP reaches 0 | Round ends, both HP reset, round counter increments |
How is this guide?
Last updated on