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 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 file

Step 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:

FieldWhat to change
myTeam.nameTeam display name
myTeam.schoolSchool name
myTeam.colorHex color matching team colors
myTeam.mascotKey matching MASCOT_SPRITES
myTeam.goalFundraising goal in dollars
myTeam.shareUrlFull URL to this team's fundraiser page
myTeam.quipsArray of battle cry strings (4–8 items)
oppTeam.*Same fields for the opposing team
startingHPHP per round (default 1000)
shieldDurationMinutesMinutes per Facebook share (default 30)
shieldHPMax damage shield absorbs (default 30)

Step 5 — Add a new mascot sprite

  1. Open components/fundraiser/BattleArena.tsx
  2. 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>;
}
  1. Register it:
const MASCOT_SPRITES = {
  tornado: (p) => <TornadoSprite {...p}/>,
  scottie: (p) => <ScottieSprite {...p}/>,
  eagle:   (p) => <EagleSprite {...p}/>,   // ← new
};
  1. 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

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

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?

Edit on GitHub

Last updated on

On this page