Building a Trading Card Game Platform: Technical Lessons from Just The Rip
Just The Rip is a digital pack-opening platform for trading card games. Users buy packs, open them with animated reveals, build collections, and trade or sell cards. Think of it as the experience of ripping open a booster pack — the anticipation, the surprise, the thrill of pulling something rare — but digital, instant, and backed by real inventory.
Building it taught us more about real-time systems, fairness in randomization, and gamified commerce than any other project we have shipped. This is the technical story of how it came together and the lessons we took away.
The Core Loop: Buy, Rip, Collect
Every feature in Just The Rip orbits a single loop: a user buys a pack, opens it to reveal cards, and those cards go into their collection. From there, they can trade cards with other users, list them for sale, or hold them.
Simple on paper. The complexity lives in the details: the opening animation has to feel exciting, the randomization has to be provably fair, the inventory has to be accurate to the individual card, and the whole thing has to handle concurrent users opening packs at the same time without double-issuing a card.
We built Just The Rip on SvelteKit with Supabase as the backend. The same stack we use for most application-heavy projects — you can read more about why in Our Tech Stack in 2026. The choice paid off here because Supabase’s real-time subscriptions and PostgreSQL’s transactional guarantees were exactly what this project needed.
Animated Card Reveals
The pack opening is the emotional center of the product. Get it wrong, and the platform feels like a spreadsheet. Get it right, and users keep coming back for the dopamine hit.
We built the reveal sequence in stages:
- Pack selection: The user sees their sealed packs. Tapping one starts the opening.
- Tear animation: A CSS animation simulates tearing open the pack wrapper.
- Card fan: Cards slide out in a fanned arrangement, face down.
- Individual reveals: Tapping each card flips it over with a 3D transform. Rare cards get a glow effect and particle burst.
- Summary: After all cards are revealed, a summary screen shows the pull with rarity breakdown.
The card flip is a CSS 3D transform. We deliberately avoided a heavy animation library — CSS handles this well and keeps the bundle lean.
/* 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 {
/* Card back design */
background: linear-gradient(135deg, #1a1a2e, #16213e);
}
.card-front {
transform: rotateY(180deg);
}
/* Rare card glow effect */
.card-inner.flipped.rare .card-front {
box-shadow:
0 0 15px rgba(255, 215, 0, 0.4),
0 0 30px rgba(255, 215, 0, 0.2),
0 0 45px rgba(255, 215, 0, 0.1);
}
.card-inner.flipped.legendary .card-front {
animation: legendary-pulse 2s ease-in-out infinite;
}
@keyframes legendary-pulse {
0%, 100% {
box-shadow:
0 0 20px rgba(168, 85, 247, 0.4),
0 0 40px rgba(168, 85, 247, 0.2);
}
50% {
box-shadow:
0 0 30px rgba(168, 85, 247, 0.6),
0 0 60px rgba(168, 85, 247, 0.3);
}
}
The particle burst on rare pulls uses a lightweight canvas animation — about 50 particles that shoot outward and fade over 800ms. We tested heavier effects and found they actually diminished the experience. The card art should be the star, not the fireworks around it.
One performance consideration: card images are preloaded as soon as the pack opening is initiated, before the user starts flipping. This happens during the tear animation, which gives us about 1.5 seconds to fetch the images. Without this, the first flip would show a loading spinner instead of the card — a mood killer.
// Preload card images during tear animation
async function preloadCards(cards: Card[]) {
const promises = cards.map((card) => {
return new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => resolve(); // Don't block on failed loads
img.src = card.imageUrl;
});
});
await Promise.all(promises);
}

Fair Randomization
This was the most technically sensitive part of the build. Users are spending real money on packs. If the randomization is not fair — or not perceived as fair — the platform loses trust instantly.
We use a weighted random selection system. Each card set defines rarity tiers with probabilities. A typical set might look like:
| Rarity | Probability | Cards per pack |
|---|---|---|
| Common | 60% | 5-6 |
| Uncommon | 25% | 2-3 |
| Rare | 10% | 0-1 |
| Legendary | 4% | 0-1 |
| Secret | 1% | 0-1 |
The selection runs entirely server-side in a PostgreSQL function. The client never sees the cards until after the transaction is committed. This prevents any client-side manipulation.
-- Weighted random card selection
CREATE OR REPLACE FUNCTION select_random_cards(
p_set_id uuid,
p_card_count integer
)
RETURNS TABLE(card jsonb) AS $$
DECLARE
v_rarity text;
v_card record;
v_roll float;
v_selected_ids uuid[] := '{}';
BEGIN
FOR i IN 1..p_card_count LOOP
-- Roll for rarity
v_roll := random();
v_rarity := CASE
WHEN v_roll < 0.01 THEN 'secret'
WHEN v_roll < 0.05 THEN 'legendary'
WHEN v_roll < 0.15 THEN 'rare'
WHEN v_roll < 0.40 THEN 'uncommon'
ELSE 'common'
END;
-- Select a random card of that rarity, avoiding duplicates in this pack
SELECT c.* INTO v_card
FROM cards c
WHERE c.set_id = p_set_id
AND c.rarity = v_rarity
AND c.id != ALL(v_selected_ids)
AND c.available_quantity > 0
ORDER BY random()
LIMIT 1;
-- Fallback to common if no cards available at rolled rarity
IF NOT FOUND THEN
SELECT c.* INTO v_card
FROM cards c
WHERE c.set_id = p_set_id
AND c.rarity = 'common'
AND c.id != ALL(v_selected_ids)
AND c.available_quantity > 0
ORDER BY random()
LIMIT 1;
END IF;
IF FOUND THEN
v_selected_ids := v_selected_ids || v_card.id;
-- Decrement available quantity atomically
UPDATE cards SET available_quantity = available_quantity - 1
WHERE id = v_card.id;
card := to_jsonb(v_card);
RETURN NEXT;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
We also implemented a “pity timer” — a mechanism that guarantees at least one rare-or-better card every N packs opened. This is common in physical TCGs and gacha games. It prevents the worst-case scenario where a user opens 20 packs and gets nothing but commons, which feels broken even if the math says it is technically possible.
The pity counter is tracked per user and per set, and it resets when a rare-or-better card is pulled naturally.
Inventory as a Source of Truth
In physical card games, inventory is simple: you have the cards in your hand. In a digital platform backed by real physical inventory, it gets complicated fast.
Every card in the system maps to either a physical card in a warehouse or a digital-only card. The platform needs to track:
- Which cards are available for pack generation
- Which cards have been pulled by users
- Which cards are listed for trade or sale
- Which cards are in transit (sold and being shipped)
- Which cards have been delivered
We modeled this as a state machine on the user_cards table:
type CardStatus =
| 'in_collection' // User owns it, sitting in their collection
| 'listed' // Listed for sale or trade
| 'pending_trade' // Trade accepted, awaiting confirmation
| 'sold' // Purchased by another user
| 'shipping' // Physical card in transit
| 'delivered'; // Physical card received
// Valid transitions
const VALID_TRANSITIONS: Record<CardStatus, CardStatus[]> = {
in_collection: ['listed'],
listed: ['in_collection', 'sold', 'pending_trade'],
pending_trade: ['in_collection', 'in_collection'], // cancel or complete
sold: ['shipping'],
shipping: ['delivered'],
delivered: [],
};
The critical constraint: the total number of cards pulled across all users must never exceed the available inventory. This is enforced at the database level with the available_quantity decrement inside the pack-opening transaction. If two users try to open packs simultaneously and there is only one card left, PostgreSQL’s row-level locking ensures only one gets it.

Real-Time Community Features
Pack openings are more fun when other people are watching. We added a live feed that shows pack openings across the platform in real time — think Twitch chat energy but for card pulls.
This uses Supabase’s real-time subscriptions. When a user opens a pack and pulls something notable (rare or above), an event is broadcast to all connected clients.
// Subscribe to live pack openings
const channel = supabase
.channel('live-pulls')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notable_pulls',
},
(payload) => {
const pull = payload.new as NotablePull;
addToFeed({
username: pull.username,
cardName: pull.card_name,
rarity: pull.rarity,
setName: pull.set_name,
imageUrl: pull.card_image_url,
timestamp: new Date(pull.created_at),
});
}
)
.subscribe();
The feed only shows notable pulls to keep it interesting. Nobody wants to see a stream of common card notifications. We also rate-limit the feed to prevent spam during high-traffic periods — a maximum of one event per second per user in the feed.
Shipping Logistics
When a user decides to cash out a physical card, the platform needs to handle shipping. We integrated with a third-party fulfillment service via webhook. The flow:
- User requests physical shipment of a card
- Platform creates a shipment request with the fulfillment partner
- Fulfillment partner picks, packs, and ships
- Tracking number is pushed back to the platform via webhook
- User sees tracking status in their collection
The tricky part is reconciliation. Physical inventory counts can drift from database counts if cards are damaged, lost, or miscounted. We run a nightly reconciliation job that compares the fulfillment partner’s physical count with our database and flags discrepancies.

Lessons for Gamified E-Commerce
A few things we would do the same way again:
Server-side everything that involves money or randomization. The client is for presentation. Every decision that affects what a user receives or pays happens in a PostgreSQL transaction. No exceptions.
Invest in the emotional moments. The pack opening animation took almost as much development time as the entire inventory system. That was the right call. Users do not remember the checkout flow. They remember the moment they pulled a rare card.
Design for the worst case. What happens when inventory runs out mid-pack? What happens when two users buy the last pack simultaneously? What happens when the fulfillment partner loses a card? Every edge case matters when real money is involved.
Make fairness visible. We added a public statistics page showing pull rates across all users. This builds trust. When users can verify that the actual pull rates match the advertised rates, complaints about “rigged” odds drop to near zero.
Building Just The Rip pushed our team to think about problems we rarely encounter in typical SaaS work — real-time state synchronization, provable fairness, physical-digital inventory bridging, and gamification psychology. It is one of the most technically interesting projects we have shipped.
If you are building a platform that blends commerce with community and gamification, we would enjoy talking about it. Reach out at [email protected].