Loot Box (10-Pull Gacha)
A gacha system where users buy a 10-pull pack and receive random items based on probability.
Step 1: Register Product on VX Shop
Before implementing, register the product on Verse8 VX Shop dashboard:
Product Settings:
- Product ID:
gacha-10pull - Product Name: Hero Gacha 10-Pull (or “10x Hero Summon”)
- Price: 500 VX (adjust as needed)
- Purchase Constraints:
- ❌ Lifetime Limit: None (unlimited purchases)
- ❌ Period Limit: None
- ❌ Time-Limited Sale: None (unless it’s an event)
Users can purchase this repeatedly to collect more heroes.
👉 Learn how to register products
Step 2: Client Implementation
import { useVXShop } from '@verse8/platform';
import { useGlobalMyState, remoteFunction } from '@agent8/gameserver';
import { useEffect, useState } from 'react';
function GachaShop() {
const { getItem, buyItem, refresh, onClose } = useVXShop();
const myState = useGlobalMyState();
const [pullResult, setPullResult] = useState(null);
useEffect(() => {
const cancel = onClose(async (payload) => {
refresh();
// If purchase was successful, open the loot box
if (payload.purchased && payload.productId === 'gacha-10pull') {
// Call server to open loot box
const result = await remoteFunction('openGachaBox', {});
setPullResult(result);
}
});
return cancel;
}, [onClose, refresh]);
const gachaItem = getItem('gacha-10pull');
const tickets = myState?.gachaTickets || 0;
// Show pull results
if (pullResult) {
return (
<div className="gacha-result">
<h3>You Got!</h3>
<div className="items-grid">
{pullResult.items.map((item, idx) => (
<div key={idx} className={`item rarity-${item.rarity}`}>
<img src={item.image} alt={item.name} />
<p>{item.name}</p>
<span className="rarity">{item.rarity}</span>
</div>
))}
</div>
<button onClick={() => setPullResult(null)}>Close</button>
</div>
);
}
return (
<div className="gacha-shop">
<h3>Hero Gacha</h3>
<p>Get 10 random heroes!</p>
<p>Your tickets: {tickets}</p>
{/* Buy more tickets */}
{gachaItem?.purchasable && (
<button onClick={() => buyItem('gacha-10pull')}>
Buy 10-Pull - {gachaItem.price} VX
</button>
)}
{/* Use existing tickets */}
{tickets >= 10 && (
<button
className="use-tickets"
onClick={async () => {
const result = await remoteFunction('openGachaBox', {});
setPullResult(result);
}}
>
Use 10 Tickets
</button>
)}
</div>
);
}Step 3: Server Implementation
// server.js
// Item pool with rarities
const GACHA_POOL = [
// Common (70%)
{ id: 'hero_1', name: 'Warrior', rarity: 'common', rate: 0.15 },
{ id: 'hero_2', name: 'Archer', rarity: 'common', rate: 0.15 },
{ id: 'hero_3', name: 'Mage', rarity: 'common', rate: 0.15 },
{ id: 'hero_4', name: 'Healer', rarity: 'common', rate: 0.15 },
{ id: 'hero_5', name: 'Knight', rarity: 'common', rate: 0.10 },
// Rare (25%)
{ id: 'hero_6', name: 'Dark Knight', rarity: 'rare', rate: 0.10 },
{ id: 'hero_7', name: 'Assassin', rarity: 'rare', rate: 0.10 },
{ id: 'hero_8', name: 'Paladin', rarity: 'rare', rate: 0.05 },
// Epic (5%)
{ id: 'hero_9', name: 'Dragon Rider', rarity: 'epic', rate: 0.03 },
{ id: 'hero_10', name: 'Phoenix Mage', rarity: 'epic', rate: 0.02 },
];
function rollGacha() {
const random = Math.random();
let cumulative = 0;
for (const item of GACHA_POOL) {
cumulative += item.rate;
if (random < cumulative) {
return item;
}
}
return GACHA_POOL[0]; // Fallback
}
async $onItemPurchased({ account, purchaseId, productId, quantity, metadata }) {
if (productId === 'gacha-10pull') {
// Give user 10 gacha tickets instead of opening immediately
const userState = await $global.getUserState(account);
const currentTickets = userState.gachaTickets || 0;
await $global.updateUserState(account, {
gachaTickets: currentTickets + 10
});
}
return { success: true };
}
async openGachaBox() {
const userState = await $global.getMyState();
const tickets = userState.gachaTickets || 0;
if (tickets < 10) {
throw new Error('Not enough tickets');
}
// Roll 10 times
const results = [];
for (let i = 0; i < 10; i++) {
results.push(rollGacha());
}
// Update user inventory
const inventory = userState.inventory || {};
results.forEach(item => {
inventory[item.id] = (inventory[item.id] || 0) + 1;
});
// Deduct tickets and update inventory
await $global.updateMyState({
gachaTickets: tickets - 10,
inventory: inventory
});
return { items: results };
}Step 4: How It Works
- User clicks “Buy 10-Pull”
- VXShop payment dialog opens
- User completes payment
- Server receives
$onItemPurchasedand gives 10 tickets - Client detects purchase completion
- Client calls
openGachaBox()remote function - Server rolls 10 random items based on rates
- Server updates inventory and deducts tickets
- Client shows animated pull results
Key Points
- Ticket system: Purchase gives tickets, not direct items
- Server-side rolling: Prevents client-side manipulation
- Probability-based: Weighted random selection
- Inventory tracking: Automatic item counting
Why Tickets Instead of Direct Opening?
Giving tickets first allows users to save them and pull later, creating better UX. Users can also use tickets without additional purchases.
Probability Distribution
The example uses:
- Common (70%): Most frequent drops
- Rare (25%): Moderate frequency
- Epic (5%): Rare drops
Adjust rates in GACHA_POOL to match your game’s economy.
Legal Compliance
Some regions require disclosing gacha rates. Always display probability information to users and comply with local regulations.
Last updated on