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:
- User buys a digital pack (or box, or case)
- User opens the pack, revealing cards one by one with animations
- Cards are added to the user’s collection
- 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.

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.

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;
}

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:
- Get the randomization right and make it auditable. Users will scrutinize the odds. Regulators might too.
- Invest heavily in the reveal experience. The animation is not decoration — it is the product. A boring reveal kills repeat usage.
- Plan for physical and digital inventory from day one. Adding physical fulfillment later is a painful retrofit.
- 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 [email protected]. This is one of the most interesting problem spaces we have worked in, and we would love to talk about it.