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 embedded on each fundraiser team's profile page at:
https://josemadrid.net/fundraise/[team-slug]
Every sale on a team's page triggers an attack. Sharing the page on Facebook activates a 30-minute shield. No manual gameplay exists — the game reflects real fundraising activity in real time.
Files Installed
components/fundraiser/BattleArena.tsx ← the entire game component
docs/BATTLE_ARENA_INTEGRATION.md ← this fileStep 1 — Embed the component in a fundraiser profile page
Your fundraiser profile page lives at: app/fundraise/[slug]/page.tsx
The page is a Server Component. BattleArena is a Client Component ("use client"). Pass data from your DB as props:
// app/fundraise/[slug]/page.tsx (Server Component — no changes needed here)
import BattleArena from "@/components/fundraiser/BattleArena";
import FundraiserBattleWrapper from "@/components/fundraiser/FundraiserBattleWrapper";
export default async function FundraiserProfilePage({ params }) {
const team = await db.fundraiserTeam.findUnique({
where: { slug: params.slug, status: "ACTIVE" },
});
if (!team) notFound();
const oppTeam = await db.fundraiserTeam.findFirst({
where: {
status: "ACTIVE",
id: { not: team.id },
activePeriod: { equals: team.activePeriod },
},
orderBy: { salesCount: "desc" },
});
const shield = await db.fundraiserShield.findFirst({
where: { teamId: team.id, expiresAt: { gt: new Date() } },
orderBy: { expiresAt: "desc" },
});
return (
<main>
{/* ... rest of your page ... */}
<FundraiserBattleWrapper
shieldExpiresAt={shield?.expiresAt?.toISOString() ?? null}
shieldHPRemaining={shield?.remainingHP ?? 0}
myTeamId={team.id}
oppTeamId={oppTeam?.id ?? null}
/>
</main>
);
}Step 2 — Create the BattleWrapper client component
This wrapper handles polling for new sale events and passing them as props:
// components/fundraiser/FundraiserBattleWrapper.tsx
"use client";
import { useState, useEffect } from "react";
import BattleArena from "./BattleArena";
interface Props {
myTeamId: string;
oppTeamId: string | null;
shieldExpiresAt: string | null;
shieldHPRemaining: number;
}
export default function FundraiserBattleWrapper({
myTeamId, oppTeamId, shieldExpiresAt, shieldHPRemaining,
}: Props) {
const [mySale, setMySale] = useState(0);
const [oppSale, setOppSale] = useState(0);
const [sharedAt, setSharedAt] = useState(false);
const [shieldExp, setShieldExp] = useState(shieldExpiresAt);
const [shieldHP, setShieldHP] = useState(shieldHPRemaining);
// Poll for new sale events every 10 seconds
useEffect(() => {
const poll = async () => {
const res = await fetch(`/api/fundraiser/battle-state?myTeam=${myTeamId}&oppTeam=${oppTeamId??""}`);
const data = await res.json();
if (data.latestMySaleDollars) setMySale(data.latestMySaleDollars + Math.random() * 0.0001);
if (data.latestOppSaleDollars) setOppSale(data.latestOppSaleDollars + Math.random() * 0.0001);
if (data.shieldExpiresAt) setShieldExp(data.shieldExpiresAt);
if (data.shieldHPRemaining !== undefined) setShieldHP(data.shieldHPRemaining);
};
poll();
const id = setInterval(poll, 10_000);
return () => clearInterval(id);
}, [myTeamId, oppTeamId]);
return (
<BattleArena
incomingSaleDollars={mySale}
opponentSaleDollars={oppSale}
shareConfirmed={sharedAt}
shieldExpiresAt={shieldExp}
shieldHPRemaining={shieldHP}
/>
);
}Step 3 — Create the battle-state API route
// app/api/fundraiser/battle-state/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const myTeamId = req.nextUrl.searchParams.get("myTeam");
const oppTeamId = req.nextUrl.searchParams.get("oppTeam");
if (!myTeamId) return NextResponse.json({ error: "myTeam required" }, { status: 400 });
// Latest sale on MY team's page (last 30 seconds to avoid replaying old events)
const mySale = await db.fundraiserSaleEvent.findFirst({
where: { teamId: myTeamId, createdAt: { gt: new Date(Date.now() - 30_000) } },
orderBy: { createdAt: "desc" },
});
const oppSale = oppTeamId ? await db.fundraiserSaleEvent.findFirst({
where: { teamId: oppTeamId, createdAt: { gt: new Date(Date.now() - 30_000) } },
orderBy: { createdAt: "desc" },
}) : null;
const shield = await db.fundraiserShield.findFirst({
where: { teamId: myTeamId, expiresAt: { gt: new Date() } },
orderBy: { expiresAt: "desc" },
});
return NextResponse.json({
latestMySaleDollars: mySale?.amount ?? 0,
latestOppSaleDollars: oppSale?.amount ?? 0,
shieldExpiresAt: shield?.expiresAt?.toISOString() ?? null,
shieldHPRemaining: shield?.remainingHP ?? 0,
});
}Step 4 — Update CONFIG in BattleArena.tsx for each team
For each new team you onboard, create a copy of BattleArena.tsx OR — better — make CONFIG a prop:
// Option A: One component per team (simplest)
// Duplicate BattleArena.tsx → BattleArena_WestM.tsx, change CONFIG block.
// Option B: Pass CONFIG as a prop (recommended for many teams)
// Change the component signature to accept a config prop and
// remove the hardcoded CONFIG const.The CONFIG fields to update per team:
| Field | What to change |
|---|---|
| myTeam.name | Team display name |
| myTeam.school | School name |
| myTeam.color | Hex color matching team colors |
| myTeam.mascot | Key matching MASCOT_SPRITES |
| myTeam.goal | Fundraising goal in dollars |
| myTeam.shareUrl | Full URL to this team's fundraiser page |
| myTeam.quips | Array of battle cry strings (4–8 items) |
| oppTeam.* | Same fields for the opposing team |
| startingHP | HP per round (default 1000) |
| shieldDurationMinutes | Minutes per Facebook share (default 30) |
| shieldHP | Max damage shield absorbs (default 30) |
Step 5 — Add a new mascot sprite
- Open components/fundraiser/BattleArena.tsx
- Add your function above the MASCOT_SPRITES object:
function EagleSprite({ color, state, tick, shielded, flipped, scale = 1 }: SpriteProps) {
// Build your SVG pixel art here
// state values: "idle" | "attack" | "hit" | "dead"
// Use tick for bob/animation: Math.sin(tick * 0.7) * 3
return <svg>...</svg>;
}- Register it:
const MASCOT_SPRITES = {
tornado: (p) => <TornadoSprite {...p}/>,
scottie: (p) => <ScottieSprite {...p}/>,
eagle: (p) => <EagleSprite {...p}/>, // ← new
};- Set mascot: "eagle" in your team's CONFIG block.
Step 6 — Shield DB schema addition
Add remainingHP to your FundraiserShield model:
model FundraiserShield {
id String @id @default(cuid())
teamId String
team FundraiserTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
activatedAt DateTime @default(now())
expiresAt DateTime
remainingHP Int @default(30) // ← add this field
@@index([teamId, expiresAt])
}Run: npx prisma migrate dev --name add_shield_remaining_hp
Update the shield route to decrement remainingHP on each opponent sale:
// In app/api/fundraiser/sale/route.ts — after processing an opp sale
// find the active shield for the team that was attacked and decrement
const activeShield = await db.fundraiserShield.findFirst({
where: { teamId: attackedTeamId, expiresAt: { gt: new Date() }, remainingHP: { gt: 0 } },
});
if (activeShield) {
const absorb = Math.min(activeShield.remainingHP, saleDollars);
await db.fundraiserShield.update({
where: { id: activeShield.id },
data: { remainingHP: { decrement: absorb } },
});
}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.
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 |
Removing the Demo Controls
The component has no demo controls in this version. All events are driven by props from your wrapper component.
How is this guide?
Last updated on