Gacha System
The Vidya Gacha system provides randomized reward mechanics through integration with the Fabricator contract and custom implementations.
Overview
The gacha system is not a single contract but a pattern implemented using:
- Fabricator Contract: For deterministic crafting with random outputs
- Custom Gacha Contracts: Game-specific randomization logic
- Oracle Integration: For verifiable randomness
Implementation Pattern
Basic Gacha Recipe Structure
// Example: Loot box recipe in Fabricator
Recipe memory lootBox = Recipe({
mintItem: MintItem({
contractAddress: gachaRewardContract,
id: 0, // Determined by gacha logic
amount: 1
}),
creator: gachaContract, // Custom gacha handler
items1155: [], // No item inputs for basic box
items20: [
Item20({
contractAddress: vidyaToken,
amount: 100 * 10**18, // 100 VIDYA to open
native: false
}),
Item20({
contractAddress: address(0),
amount: 0.01 ether, // Small ETH fee
native: true
})
]
});
Custom Gacha Contract Interface
interface IGachaSystem {
// Rarity tiers
enum Rarity { Common, Uncommon, Rare, Epic, Legendary }
// Reward structure
struct Reward {
uint256 tokenId;
uint256 amount;
Rarity rarity;
}
// Gacha configuration
struct GachaConfig {
uint256[] rarityWeights; // [600, 250, 120, 25, 5] = 60%, 25%, 12%, 2.5%, 0.5%
mapping(Rarity => uint256[]) rewardPools;
uint256 seed;
}
// Events
event GachaOpened(
address indexed player,
uint256 indexed gachaType,
Reward[] rewards
);
event PityTriggered(
address indexed player,
uint256 pullCount
);
// Functions
function openGacha(uint256 gachaType) external payable returns (Reward[] memory);
function openGachaBatch(uint256 gachaType, uint256 quantity) external payable returns (Reward[] memory);
function getPullHistory(address player) external view returns (uint256);
function getDropRates(uint256 gachaType) external view returns (uint256[] memory);
}
Implementation Examples
Basic Randomization Logic
contract SimpleGacha {
using SafeMath for uint256;
mapping(address => uint256) public pullsSinceRare;
uint256 constant PITY_THRESHOLD = 10;
function determineRarity(uint256 randomSeed, address player) internal returns (Rarity) {
pullsSinceRare[player] = pullsSinceRare[player].add(1);
// Pity system - guaranteed rare+ after 10 pulls
if (pullsSinceRare[player] >= PITY_THRESHOLD) {
pullsSinceRare[player] = 0;
uint256 roll = randomSeed % 1000;
if (roll < 50) return Rarity.Legendary; // 5%
if (roll < 200) return Rarity.Epic; // 15%
return Rarity.Rare; // 80%
}
// Normal rates
uint256 roll = randomSeed % 1000;
if (roll < 5) {
pullsSinceRare[player] = 0;
return Rarity.Legendary; // 0.5%
}
if (roll < 30) {
pullsSinceRare[player] = 0;
return Rarity.Epic; // 2.5%
}
if (roll < 150) {
pullsSinceRare[player] = 0;
return Rarity.Rare; // 12%
}
if (roll < 400) return Rarity.Uncommon; // 25%
return Rarity.Common; // 60%
}
function selectReward(Rarity rarity, uint256 randomSeed) internal view returns (uint256) {
uint256[] storage pool = rewardPools[rarity];
require(pool.length > 0, "Empty reward pool");
uint256 index = randomSeed % pool.length;
return pool[index];
}
}
Oracle Integration
contract OracleGacha {
IRandomnessOracle public oracle;
mapping(uint256 => address) public requestToPlayer;
function requestGacha() external payable {
uint256 requestId = oracle.requestRandomness{value: msg.value}();
requestToPlayer[requestId] = msg.sender;
}
function fulfillRandomness(uint256 requestId, uint256 randomness) external {
require(msg.sender == address(oracle), "Only oracle");
address player = requestToPlayer[requestId];
Rarity rarity = determineRarity(randomness, player);
uint256 rewardId = selectReward(rarity, randomness >> 128);
// Mint reward
IInventoryV1155(inventory).mint(player, rewardId, 1);
emit GachaOpened(player, 0, [Reward(rewardId, 1, rarity)]);
}
}
Integration with Fabricator
Gacha Recipe Handler
contract GachaFabricatorHandler {
Fabricator public fabricator;
IRandomnessOracle public oracle;
// Called when Fabricator transfers tokens to this contract
function onERC20Received(
address from,
uint256 amount,
bytes calldata data
) external returns (bytes4) {
require(msg.sender == address(vidyaToken), "Only VIDYA");
// Decode gacha type from data
uint256 gachaType = abi.decode(data, (uint256));
// Request randomness for the player
uint256 requestId = oracle.requestRandomness();
pendingRequests[requestId] = PendingRequest(from, gachaType);
return this.onERC20Received.selector;
}
function fulfillGacha(uint256 requestId, uint256 randomness) external {
PendingRequest memory request = pendingRequests[requestId];
// Determine and mint rewards
Reward[] memory rewards = processGacha(request.gachaType, randomness);
for (uint i = 0; i < rewards.length; i++) {
IInventoryV1155(inventory).mint(
request.player,
rewards[i].tokenId,
rewards[i].amount
);
}
emit GachaCompleted(request.player, rewards);
}
}
Drop Rate Configuration
Standard Rarity Distribution
contract GachaConfig {
struct TierConfig {
string name;
uint256 weight; // Out of 1000
uint256[] itemIds;
}
TierConfig[] public tiers;
constructor() {
// Initialize standard tiers
tiers.push(TierConfig("Common", 600, [/* item IDs */]));
tiers.push(TierConfig("Uncommon", 250, [/* item IDs */]));
tiers.push(TierConfig("Rare", 120, [/* item IDs */]));
tiers.push(TierConfig("Epic", 25, [/* item IDs */]));
tiers.push(TierConfig("Legendary", 5, [/* item IDs */]));
}
function getDropRates() external view returns (
string[] memory names,
uint256[] memory rates
) {
names = new string[](tiers.length);
rates = new uint256[](tiers.length);
for (uint i = 0; i < tiers.length; i++) {
names[i] = tiers[i].name;
rates[i] = tiers[i].weight;
}
}
}
Best Practices
1. Transparency
Always make drop rates publicly viewable:
function getDropRateInfo() external view returns (string memory) {
return string(abi.encodePacked(
"Common: 60%\n",
"Uncommon: 25%\n",
"Rare: 12%\n",
"Epic: 2.5%\n",
"Legendary: 0.5%"
));
}
2. Pity System
Implement bad luck protection:
mapping(address => mapping(uint256 => uint256)) public pullsSinceRarity;
function checkPity(address player, uint256 gachaType, Rarity minRarity) internal view returns (bool) {
uint256 pulls = pullsSinceRarity[player][gachaType];
if (minRarity >= Rarity.Rare && pulls >= 10) return true;
if (minRarity >= Rarity.Epic && pulls >= 50) return true;
if (minRarity >= Rarity.Legendary && pulls >= 100) return true;
return false;
}
3. Bulk Operations
Support efficient multi-pulls:
function multiPull(uint256 gachaType, uint256 quantity) external payable {
require(quantity <= 10, "Max 10 pulls at once");
require(msg.value >= PULL_COST * quantity, "Insufficient payment");
Reward[] memory allRewards = new Reward[](quantity);
uint256 randomness = oracle.getRandomness();
for (uint i = 0; i < quantity; i++) {
// Use different parts of randomness for each pull
uint256 seed = uint256(keccak256(abi.encode(randomness, i)));
allRewards[i] = processSinglePull(gachaType, seed);
}
_mintRewards(msg.sender, allRewards);
emit BulkGachaOpened(msg.sender, gachaType, allRewards);
}
Security Considerations
- Randomness Source: Use a secure oracle or commit-reveal scheme
- Front-running: Implement request-fulfillment pattern for randomness
- Economic Balance: Carefully balance drop rates and costs
- Reentrancy: Protect against reentrancy in reward distribution
- Access Control: Restrict configuration changes to authorized roles
Events for Analytics
event GachaOpened(
address indexed player,
uint256 indexed gachaType,
uint256 timestamp,
Reward[] rewards,
uint256 totalValue
);
event RarityDistribution(
uint256 indexed gachaType,
uint256 common,
uint256 uncommon,
uint256 rare,
uint256 epic,
uint256 legendary
);
event PlayerSpending(
address indexed player,
uint256 totalSpent,
uint256 totalPulls,
uint256 avgValue
);