Back to Blog

How to Build a Pack-Opening or Gamified E-Commerce Experience

How to Build a Pack-Opening or Gamified E-Commerce Experience

When we built Just The Rip, a digital pack-opening platform for trading card games, we entered a space where e-commerce meets gaming. The product needed to feel like opening a real pack of cards — the anticipation, the reveal, the thrill of pulling something rare. But it also needed to work like a real store — inventory tracking, payment processing, shipping, order fulfillment.

The intersection of those two worlds created technical challenges we had never encountered building traditional e-commerce or traditional gaming products. Here is how we solved them.

The Core Loop: Buy, Open, Collect, Trade

The fundamental user experience in a pack-opening platform is simple to describe and complex to build:

  1. User buys a digital pack (or box, or case)
  2. User opens the pack, revealing cards one by one with animations
  3. Cards are added to the user’s collection
  4. User can trade, sell, or request physical shipment of their cards

Every step has technical complexity hiding underneath it. Buying a pack involves inventory management and payment processing. Opening a pack involves randomization with rarity guarantees and animated reveals. Collecting involves a real-time inventory system. Trading involves a marketplace with escrow-like mechanics. Shipping involves connecting digital inventory to physical fulfillment.

Randomization That Feels Fair

The randomization engine is the heart of the platform. It determines what cards a user gets when they open a pack. Get this wrong and users lose trust immediately.

The key constraint: randomization must be verifiable and match the published odds. If a pack says it contains “1 rare or better card guaranteed,” the system must deliver that every single time. This is not just a user experience concern — in many jurisdictions, pack-opening mechanics that involve real money are subject to gambling-adjacent regulations.

We built a two-layer randomization system:

interface PackConfiguration {
  id: string;
  name: string;
  slots: SlotConfiguration[];
  total_cards: number;
}

interface SlotConfiguration {
  position: number;
  rarity_pool: RarityPool;
  guaranteed_minimum: Rarity | null;
}

interface RarityPool {
  weights: Record<Rarity, number>; // e.g., { common: 70, uncommon: 20, rare: 8, ultra_rare: 2 }
  available_cards: Record<Rarity, string[]>; // card IDs by rarity
}

function generatePackContents(config: PackConfiguration): string[] {
  const contents: string[] = [];

  for (const slot of config.slots) {
    const rarity = selectRarity(slot.rarity_pool.weights, slot.guaranteed_minimum);
    const availableCards = slot.rarity_pool.available_cards[rarity];
    const selectedCard = availableCards[cryptoRandomInt(availableCards.length)];
    contents.push(selectedCard);
  }

  return contents;
}

function selectRarity(
  weights: Record<Rarity, number>,
  minimum: Rarity | null
): Rarity {
  // Filter out rarities below the guaranteed minimum
  const eligibleWeights = minimum
    ? filterBelowMinimum(weights, minimum)
    : weights;

  // Normalize weights and select using crypto-random
  const totalWeight = Object.values(eligibleWeights).reduce((a, b) => a + b, 0);
  const roll = cryptoRandomInt(totalWeight);

  let cumulative = 0;
  for (const [rarity, weight] of Object.entries(eligibleWeights)) {
    cumulative += weight;
    if (roll < cumulative) return rarity as Rarity;
  }

  // Fallback (should never reach here)
  return Object.keys(eligibleWeights)[0] as Rarity;
}

function cryptoRandomInt(max: number): number {
  const array = new Uint32Array(1);
  crypto.getRandomValues(array);
  return array[0] % max;
}

We use crypto.getRandomValues instead of Math.random for two reasons: it produces cryptographically secure randomness, and it is auditable. We log the random seed for every pack opening so results can be verified after the fact if a user disputes their pulls.

The slot-based configuration means each position in the pack can have different odds. Slot 1 through 8 might use the standard rarity distribution, while slot 9 has a guaranteed rare-or-better minimum. This matches how real trading card packs work.

Trading cards spread out on a table ready for a card game

The Animation System

The animation layer is where gamified e-commerce diverges completely from standard e-commerce. A regular store shows you what you bought. A pack-opening platform builds anticipation and delivers a reveal.

We built the card reveal animation system using a state machine that coordinates timing, visual effects, and user interaction:

type RevealState =
  | 'sealed'        // Pack is unopened
  | 'opening'       // Opening animation playing
  | 'ready'         // Cards face-down, ready to flip
  | 'revealing'     // Card flip animation in progress
  | 'revealed'      // Card face-up, showing result
  | 'complete';     // All cards revealed

interface CardRevealController {
  state: RevealState;
  currentCard: number;
  totalCards: number;
  cards: PackCard[];

  // User interactions
  flipNext(): void;
  flipAll(): void; // Skip ahead

  // Animation callbacks
  onFlipStart: (index: number) => void;
  onFlipComplete: (index: number, card: PackCard) => void;
  onRareReveal: (index: number, card: PackCard) => void;
  onAllRevealed: (cards: PackCard[]) => void;
}

The onRareReveal callback is critical for the experience. When a rare card is about to be revealed, the animation changes — the card might glow, the background might shift, sound effects might play. These cues build anticipation before the card flips, matching the experience of seeing a different card stock or holofoil pattern before you read the card name in a physical pack.

The CSS for the card flip animation:

.card-container {
  perspective: 1000px;
  width: 250px;
  height: 350px;
}

.card-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
  transform-style: preserve-3d;
}

.card-inner.flipped {
  transform: rotateY(180deg);
}

.card-front,
.card-back {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  border-radius: 12px;
  overflow: hidden;
}

.card-back {
  transform: rotateY(180deg);
}

/* Rare card glow effect */
.card-inner.rare::before {
  content: '';
  position: absolute;
  inset: -4px;
  border-radius: 16px;
  background: conic-gradient(
    from var(--glow-angle, 0deg),
    #ff6b6b,
    #ffd93d,
    #6bcb77,
    #4d96ff,
    #ff6b6b
  );
  animation: glow-rotate 3s linear infinite;
  z-index: -1;
}

@keyframes glow-rotate {
  to { --glow-angle: 360deg; }
}

@property --glow-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

The conic-gradient glow on rare cards uses the @property CSS rule to animate a custom property, creating a smooth rotating rainbow border effect. This kind of visual polish is what separates a platform that feels exciting from one that feels like a spreadsheet with images.

Inventory Management: Digital and Physical

The most complex part of the system is inventory management, because Just The Rip deals with both digital and physical inventory simultaneously.

A physical card exists in a warehouse. When a user opens a digital pack, the system assigns specific physical cards to the digital result. The user then decides: keep the card in their digital collection (we hold the physical card) or request shipment (we send it to them).

This creates a three-state inventory model:

type InventoryState =
  | 'available'        // In warehouse, not assigned to any user
  | 'assigned_digital' // Assigned to a user's digital collection
  | 'shipment_pending' // User requested physical shipment
  | 'shipped'          // In transit to user
  | 'delivered';       // Confirmed delivered

interface InventoryItem {
  id: string;
  card_id: string;
  physical_location: string; // warehouse bin/slot
  condition: 'mint' | 'near_mint' | 'excellent' | 'good';
  state: InventoryState;
  assigned_to: string | null; // user ID
  assigned_at: Date | null;
  pack_opening_id: string | null; // trace back to the opening
}

The critical constraint: when the randomization engine selects a card for a pack opening, it must atomically reserve a physical copy from inventory. If two users open packs simultaneously and both should receive the same rare card, the system cannot assign the same physical copy to both.

We handle this with PostgreSQL advisory locks:

-- Atomic card assignment during pack opening
CREATE OR REPLACE FUNCTION assign_card_from_inventory(
  p_card_id UUID,
  p_user_id UUID,
  p_pack_opening_id UUID
) RETURNS UUID AS $$
DECLARE
  v_inventory_id UUID;
BEGIN
  -- Lock and select an available physical copy
  SELECT id INTO v_inventory_id
  FROM inventory_items
  WHERE card_id = p_card_id
    AND state = 'available'
  ORDER BY created_at ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED;

  IF v_inventory_id IS NULL THEN
    RAISE EXCEPTION 'No available inventory for card %', p_card_id;
  END IF;

  -- Assign to user
  UPDATE inventory_items
  SET state = 'assigned_digital',
      assigned_to = p_user_id,
      assigned_at = now(),
      pack_opening_id = p_pack_opening_id
  WHERE id = v_inventory_id;

  RETURN v_inventory_id;
END;
$$ LANGUAGE plpgsql;

The FOR UPDATE SKIP LOCKED clause is essential. It locks the selected row to prevent double-assignment, and SKIP LOCKED ensures that if another transaction has already locked a copy, the query moves to the next available one instead of waiting. This gives us concurrent pack openings without deadlocks.

Unboxing a product package to reveal its contents

Real-Time Multiplayer Openings

One of the most engaging features is group pack openings — multiple users opening packs simultaneously, seeing each other’s results in real time. This creates a social, streaming-like experience.

We built this with Supabase Realtime channels:

interface OpeningRoom {
  id: string;
  host_id: string;
  participants: string[];
  state: 'lobby' | 'opening' | 'results';
}

// Client-side: joining and participating in a room
function joinOpeningRoom(roomId: string, userId: string) {
  const channel = supabase.channel(`room:${roomId}`);

  channel
    .on('broadcast', { event: 'card_revealed' }, (payload) => {
      // Another user revealed a card
      const { userId: revealerId, cardIndex, card } = payload.payload;
      showOtherUserReveal(revealerId, cardIndex, card);
    })
    .on('broadcast', { event: 'rare_pull' }, (payload) => {
      // Someone pulled a rare card — show celebration
      const { userId: pullerId, card } = payload.payload;
      showRarePullCelebration(pullerId, card);
    })
    .on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState();
      updateParticipantList(state);
    })
    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.track({ userId, status: 'ready' });
      }
    });

  return channel;
}

// Server-side: broadcasting a reveal
async function broadcastCardReveal(
  roomId: string,
  userId: string,
  cardIndex: number,
  card: PackCard
) {
  await supabase.channel(`room:${roomId}`).send({
    type: 'broadcast',
    event: 'card_revealed',
    payload: { userId, cardIndex, card },
  });

  // If it's a rare+ card, send a special event
  if (card.rarity >= Rarity.Rare) {
    await supabase.channel(`room:${roomId}`).send({
      type: 'broadcast',
      event: 'rare_pull',
      payload: { userId, card },
    });
  }
}

The separation between card_revealed and rare_pull events lets the client handle them differently. A normal reveal updates the other user’s card display. A rare pull triggers a room-wide celebration animation — confetti, sound effects, the works. This makes group openings feel like a shared event, not just parallel individual experiences.

Shipping Integration

When a user requests physical shipment of their cards, the system needs to aggregate cards, calculate shipping costs, generate labels, and track delivery.

We batch shipping requests to reduce costs — if a user requests shipment of five cards over the course of a week, we combine them into a single package:

interface ShipmentBatch {
  id: string;
  user_id: string;
  items: InventoryItem[];
  shipping_address: Address;
  carrier: 'usps' | 'ups' | 'fedex';
  tracking_number: string | null;
  status: 'pending' | 'packed' | 'labeled' | 'shipped' | 'delivered';
  batch_window_closes_at: Date; // Auto-ship after this date
}

// Cron job: close batches and generate shipping labels
async function processShipmentBatches() {
  const readyBatches = await supabase
    .from('shipment_batches')
    .select('*, items:inventory_items(*)')
    .eq('status', 'pending')
    .lt('batch_window_closes_at', new Date().toISOString());

  for (const batch of readyBatches.data ?? []) {
    const label = await shippingProvider.createLabel({
      from: WAREHOUSE_ADDRESS,
      to: batch.shipping_address,
      weight: estimateWeight(batch.items),
      dimensions: estimateDimensions(batch.items),
    });

    await supabase
      .from('shipment_batches')
      .update({
        status: 'labeled',
        tracking_number: label.tracking_number,
      })
      .eq('id', batch.id);
  }
}

Anti-Fraud and Fair Play

Any platform involving randomized rewards and real money attracts people trying to game the system. We implemented several layers of protection:

Server-side randomization only. The client never knows what is in a pack until the server reveals it. The animation plays on the client, but the result is determined and stored server-side before the first card flip happens.

Rate limiting on pack purchases. Unusual purchase patterns — buying 100 packs in two minutes — trigger a review flag. This catches both automated buying and stolen credit cards.

Randomization auditing. Every pack opening is logged with its random seed, the resulting cards, and a timestamp. We can reconstruct any opening after the fact and verify the odds were applied correctly.

interface PackOpeningAuditLog {
  id: string;
  user_id: string;
  pack_config_id: string;
  random_seed: string;
  result_card_ids: string[];
  inventory_item_ids: string[];
  rarity_distribution: Record<Rarity, number>;
  opened_at: Date;
  ip_address: string;
}

Gamification elements driving user engagement on a platform

Lessons for Gamified E-Commerce Builders

Building Just The Rip taught us that gamified e-commerce is harder than either pure gaming or pure e-commerce, because you inherit the hard problems of both domains. You need the inventory precision and payment reliability of e-commerce plus the real-time performance and emotional design of gaming.

If you are considering building a similar platform — whether it is digital card packs, mystery boxes, gacha mechanics, or any other randomized reward system — here is what to prioritize:

  1. Get the randomization right and make it auditable. Users will scrutinize the odds. Regulators might too.
  2. Invest heavily in the reveal experience. The animation is not decoration — it is the product. A boring reveal kills repeat usage.
  3. Plan for physical and digital inventory from day one. Adding physical fulfillment later is a painful retrofit.
  4. Build real-time features early. Social features like group openings drive engagement and virality.

For more on our approach to e-commerce architecture, see our post on custom e-commerce vs. Shopify vs. headless. For real-time implementation details, see building real-time features with Supabase.

If you are building a gamified e-commerce experience or a platform that blends entertainment with transactions, reach out at hello@threshline.com. This is one of the most interesting problem spaces we have worked in, and we would love to talk about it.