Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

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

PiecePath
Full arena UIapp/(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 widgetcomponents/arena/battle-widget.tsx (rendered by app/fundraise/[slug]/page.tsx)
Client pollinglib/arena/use-arena-state.ts
Snapshot builderlib/arena/server-state.ts
Damagelib/arena/damage.ts (applyPurchaseDamage)
Shieldslib/arena/shield-service.ts, lib/arena/share-nonce.ts
Constantslib/arena/rules.ts (SHIELD_DURATION_MS, SHIELD_MAX_HP)
Standingslib/arena/standings.ts (getBattleStandings)

API routes

RoutePurpose
GET /api/fundraiser/arena/[period]/statePublic arena snapshot (HP, shields, feed). Rate-limited to 120 req/min per IP.
GET/POST /api/fundraiser/arena/[period]/messagesArena chat feed (no page posts to it since the 3D game replaced the 2D arena)
POST /api/fundraiser/arena/share/intentMints a single-use share nonce; redeeming /s/[nonce] activates the shield
POST /api/fundraiser/saleMachine-to-machine sale ingest (HMAC API key, idempotent on orderId)
POST /api/fundraiser/sale-events/[id]/love, .../replyReactions 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

EventResult
$45 sale on MY pageMY mascot attacks for 45 damage
$100 sale on OPP pageOPP mascot attacks for 100 damage
Shield active, OPP $100 saleShield absorbs up to $30, remaining 70 hits HP
Shield active, OPP $20 saleShield absorbs all 20, mascot HP unchanged
Shield at 0 HP remainingShield 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 0Round ends, both HP reset, round counter increments

How is this guide?

Edit on GitHub

Last updated on

On this page