Skip to main content

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

  1. Randomness Source: Use a secure oracle or commit-reveal scheme
  2. Front-running: Implement request-fulfillment pattern for randomness
  3. Economic Balance: Carefully balance drop rates and costs
  4. Reentrancy: Protect against reentrancy in reward distribution
  5. 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
);