DEV Community

Cover image for Building a 700+ Card Browser RPG with React 19, Vite 8, and Zero Monetization
TheLuigiplayer
TheLuigiplayer

Posted on Edited on

Building a 700+ Card Browser RPG with React 19, Vite 8, and Zero Monetization

I started working on Llama Legends, a browser-based card collector, in mid-December 2025. The goal was simple: an idle RPG with the parts of the genre I actually enjoy (cracking booster packs, hunting rare holographic finishes, assembling team synergies, fighting world bosses) and none of the parts I don't. No energy timers. No paywalls. No ads in the game, no purchases, no donations.

Artwork and AI disclosure

A card-collecting game lives on variety, and the pool is now over 700 cards: pop culture parodies, mythology, historical figures, and a handful of deep cuts. Commissioning 700+ illustrations for a self-funded hobby project was never realistic. I generate the card art with Google's Nano Banana instead. That workflow is why I can ship weekly updates and seasonal events as one person, and it is also why the game stays free and non-commercial.

Here is the architecture, and what I learned about rendering 700+ cards without the hover feeling sticky.

The stack

Llama Legends is a single-page app built with React 19, TypeScript, Tailwind CSS and Vite 8, hosted as static files on Cloudflare Pages.

When you share an indie web game, forcing an account before the first pack costs you a large share of first-time visitors. So the game uses a hybrid model:

  1. Guest mode by default. New players start immediately. Inventory, progression and idle timers persist to localStorage.
  2. Optional account linking. Clans, leaderboards and cross-device sync need an account: Firebase Authentication for the login, Firestore for the save.
  3. Server-authoritative raid scoring. Client-side games are easy to edit in DevTools, so world boss damage and the leaderboard pass through Cloud Functions before anything is written to Firestore.

Card rendering: tilt, glare, no layout thrashing

Every pull rolls a condition grade between 1.0 and 10.0 in half steps. A perfect 10 shows up about 1.5% of the time; most cards land in the middle. On top of that, each card gets one of five finishes (plain, glitter, holo, foil, gold) and one of two layouts (standard or full art, the borderless one). That is ten visual variants per card, before you count the grade.

The tilt is where a card grid starts to feel bad. My first version read getBoundingClientRect() inside the move handler and pushed the angle into useState, which meant a layout read and a React render per pointer move. On a full album page, that is enough to make the hover feel sticky.

What shipped caches the rect for the duration of the hover and writes straight into Framer Motion MotionValues, which Card.tsx maps to CSS custom properties:

const x = useMotionValue(0);
const y = useMotionValue(0);
const springConfig = { damping: 20, stiffness: 150, mass: 1 };
const rotateX = useSpring(x, springConfig);
const rotateY = useSpring(y, springConfig);

// Cached once per hover and invalidated on leave, so the move handler
// never forces a layout read.
const rectRef = useRef<DOMRect | null>(null);

const updateTilt = (clientX: number, clientY: number) => {
  if (!ref.current) return;
  if (!rectRef.current) rectRef.current = ref.current.getBoundingClientRect();

  const rect = rectRef.current;
  const centerX = rect.width / 2;
  const centerY = rect.height / 2;
  const sensitivity = 16;

  // Clamped targets. The springs do the smoothing.
  x.set(Math.max(-20, Math.min(20, ((clientY - rect.top - centerY) / centerY) * -sensitivity)));
  y.set(Math.max(-20, Math.min(20, ((clientX - rect.left - centerX) / centerX) * sensitivity)));
};
Enter fullscreen mode Exit fullscreen mode

Two details matter. The rect is cached on hover-in and thrown away on leave, so resizes and layout shifts still re-measure. And the rotation targets are clamped as a safety net: a pointer outside the card cannot push the angle past 20 degrees.

For the holographic glare, animating background-position from the pointer was the second trap. It repaints the element on every pointer move, and on Firefox the card visibly stuttered. The glare is now a single oversized gradient layer that is only translated, which the compositor handles without repainting:

export const GlobalGlare: React.FC<{ glareOpacityMultiplier: number }> = ({
  glareOpacityMultiplier,
}) => (
  <div
    className="absolute inset-0 rounded-[inherit] overflow-hidden pointer-events-none mix-blend-overlay z-40"
    style={{ opacity: `calc(var(--glare-opacity, 0) * ${glareOpacityMultiplier})` }}
  >
    <div
      className="absolute left-0 top-0 w-[300%] h-[300%]"
      style={{
        background:
          'linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.4) 50%, transparent 60%)',
        transform: `translate3d(calc(-66.667% * var(--pointer-from-left, 0.5)), calc(-66.667% * var(--pointer-from-top, 0.5)), 0)`,
        willChange: 'transform',
      }}
    />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Because the gradient sits in a 300% box, a translation of -66.667% maps exactly to a 0% to 100% background-position. Same look, no repaint. The finishes themselves are CSS too: layered gradients, a tiling glitter texture and mix-blend-mode, not shaders.

700+ cards in the DOM: album spreads instead of virtualization

Past 700 unique cards, one endless scrolling grid started to degrade, especially on mobile. Instead of reaching for a virtualized list for the album, I modeled the collection after a physical card binder: a paginated two-page spread.

Only the visible spread is mounted, plus one page while it flips. To make the flip feel instant, a preload hook warms two rings:

// First ring: the visible spread (high priority) plus its two neighbours.
const urlsToPreload: { url: string; highPriority: boolean }[] = [];
[currentSpread, currentSpread - 1, currentSpread + 1].forEach((spread) =>
  collectSpread(spread, urlsToPreload)
);
urlsToPreload.forEach(({ url, highPriority }) => startLoad(url, highPriority));

// Second ring (spread Âą 2) waits for idle time so it never competes
// with the decode work of the spread you are looking at.
const preloadSecondRing = (): void => {
  if (cancelled) return;
  const ring: { url: string; highPriority: boolean }[] = [];
  [currentSpread - 2, currentSpread + 2].forEach((spread) => collectSpread(spread, ring));
  ring.forEach(({ url }) => startLoad(url, false));
};

const idleId =
  typeof window.requestIdleCallback === 'function'
    ? window.requestIdleCallback(preloadSecondRing)
    : window.setTimeout(preloadSecondRing, 350);
Enter fullscreen mode Exit fullscreen mode

The first ring (current spread and its neighbours) loads immediately, with fetchPriority = 'high' on the visible spread. The second ring (plus or minus two spreads) waits for requestIdleCallback, with a 350 ms timeout for browsers that don't have it. Pending decodes are aborted when the spread changes, so a fast flip doesn't keep downloading pages you already left.

The album grid also requests the 512 px md thumbnails instead of the 1024 px artwork. For a square image, 512² is a quarter of the pixels of 1024², so decoded image memory drops by about three-quarters per spread.

Play it

If you want to feel the tilt or open a few packs, the game runs in the browser without an account:

Halloween 2026

The Halloween 2026 event runs until November 13, and so does the promo code. The Haunted Tower has 50 floors, and only the Pumpkin Knight may enter it, so get him first: open the gift box (desktop HUD, or the menu on mobile) and redeem THELONGNIGHT2026. If you are brand new, the gift box appears after completing the onboarding.

On October 31, the world boss The Shadow Lord goes live for everyone with an account. The Pumpkin Knight deals double damage against him, and every player who lands damage gets the boss card once he falls.

Feedback on the UI responsiveness, how the card effects feel, or early-game balance is very welcome. If you have questions about running a multiplayer browser game on Firebase and Cloudflare with a near-zero bill, ask in the comments. :)

Top comments (0)