Разработка системы энергии/выносливости GameFi
Система энергии — механика ограничения игровой активности. Игрок тратит энергию на действия (битвы, фарминг, крафт), энергия восполняется со временем или через покупку. В Web2 это просто счётчик в базе данных. В Web3 это on-chain ресурс, что создаёт и возможности (tradeable энергия, verifiable regen), и проблемы (gas за каждое обновление, cheating prevention).
Правильная архитектура энергетической системы — одна из ключевых инженерных задач GameFi. Её неправильная реализация либо делает игру неиграбельной (слишком много on-chain операций), либо открывает эксплойты (бесплатная энергия через manipulation).
Ключевая проблема: time-based regen без постоянного on-chain обновления
Интуитивное решение: хранить энергию в mapping, обновлять каждую секунду. Это плохо — бесконечное количество транзакций.
Правильный подход: lazy evaluation. Храним не текущую энергию, а момент последнего изменения и значение в тот момент. Текущая энергия вычисляется on-the-fly при каждом чтении:
contract EnergySystem {
struct EnergyState {
uint128 storedEnergy; // энергия на момент lastUpdate
uint64 lastUpdateTime;
uint64 maxEnergy;
}
mapping(address => EnergyState) private energyStates;
uint256 public constant REGEN_RATE = 1e18; // 1 энергия в секунду (18 decimals)
uint256 public constant MAX_ENERGY = 100e18;
// Вычисляем текущую энергию без записи в storage
function currentEnergy(address player) public view returns (uint256) {
EnergyState storage state = energyStates[player];
uint256 elapsed = block.timestamp - state.lastUpdateTime;
uint256 regenerated = elapsed * REGEN_RATE;
uint256 total = uint256(state.storedEnergy) + regenerated;
uint256 max = state.maxEnergy == 0 ? MAX_ENERGY : uint256(state.maxEnergy);
return total > max ? max : total;
}
// Обновляем storage только при фактическом использовании/изменении энергии
function _updateEnergyState(address player) internal {
EnergyState storage state = energyStates[player];
state.storedEnergy = uint128(currentEnergy(player));
state.lastUpdateTime = uint64(block.timestamp);
}
function spendEnergy(address player, uint256 amount) internal {
uint256 current = currentEnergy(player);
require(current >= amount, "Insufficient energy");
_updateEnergyState(player);
energyStates[player].storedEnergy -= uint128(amount);
}
function addEnergy(address player, uint256 amount) internal {
_updateEnergyState(player);
uint256 max = energyStates[player].maxEnergy == 0
? MAX_ENERGY
: energyStates[player].maxEnergy;
uint256 newEnergy = uint256(energyStates[player].storedEnergy) + amount;
energyStates[player].storedEnergy = uint128(newEnergy > max ? max : newEnergy);
}
}
Ключевой момент: currentEnergy() — view функция, не тратит gas. Storage обновляется только при spendEnergy/addEnergy — то есть при реальном игровом действии. Gas экономия существенная: не нужны отдельные транзакции для regen.
Привязка к NFT: энергия персонажа
Энергия привязана к конкретному NFT, не к EOA кошельку. Это важно: игрок может иметь несколько персонажей с независимой энергией, торговать персонажами вместе с их текущей энергией.
contract CharacterEnergySystem {
struct CharacterEnergy {
uint128 storedEnergy;
uint64 lastUpdate;
uint8 tier; // tier персонажа влияет на max energy и regen
}
mapping(uint256 => CharacterEnergy) public characterEnergy; // tokenId → energy
// Regen rate зависит от tier персонажа
function regenRateForTier(uint8 tier) public pure returns (uint256) {
if (tier == 3) return 3e18; // 3 ед/сек для legendary
if (tier == 2) return 2e18; // 2 ед/сек для rare
return 1e18; // 1 ед/сек для common
}
// Max energy зависит от tier
function maxEnergyForTier(uint8 tier) public pure returns (uint256) {
return 100e18 + uint256(tier) * 50e18; // 100, 150, 200 для tier 1,2,3
}
function currentEnergy(uint256 tokenId) public view returns (uint256) {
CharacterEnergy storage ce = characterEnergy[tokenId];
uint8 tier = nftContract.getTier(tokenId);
uint256 elapsed = block.timestamp - ce.lastUpdate;
uint256 regen = elapsed * regenRateForTier(tier);
uint256 total = uint256(ce.storedEnergy) + regen;
uint256 max = maxEnergyForTier(tier);
return total > max ? max : total;
}
// При трансфере NFT: энергия переходит с персонажем
// (хранится в tokenId маппинге, не в owner маппинге — автоматически)
}
Energy token: tradeable энергия
Более сложная модель: отдельный ERC-20 токен как энергия, которую можно купить/продать.
// ENERGY_TOKEN: ERC-20 с burn при использовании
contract EnergyToken is ERC20, AccessControl {
bytes32 public constant GAME_ROLE = keccak256("GAME_ROLE");
// Game контракт минтит при regen, сжигает при использовании
function mintRegen(address player, uint256 amount) external onlyRole(GAME_ROLE) {
_mint(player, amount);
}
function burnForAction(address player, uint256 amount) external onlyRole(GAME_ROLE) {
_burn(player, amount);
}
}
Trade-off ERC-20 энергии: игроки могут купить энергию на DEX → pay-to-win риск. Если это допустимо в вашей модели — ERC-20 энергия даёт экономическую ценность. Если нет — энергия должна быть non-transferable (не токен, а internal accounting).
Anti-cheat: защита от манипуляций
Problem: flash-refill через re-org или timestamp manipulation
Майнер (в PoW) или validator (в PoS) технически может манипулировать block.timestamp в небольших пределах (~15 секунд). Для энергетической системы это незначительно.
Более реальная угроза: off-chain server атаки. Если game server выдаёт подписи для игровых действий — игрок может replay старую подпись.
// Защита через signed action с nonce
struct GameAction {
uint256 characterId;
uint256 actionType;
uint256 energyCost;
uint256 nonce; // одноразовый, инкрементальный
uint256 deadline; // транзакция должна быть подана до
}
mapping(address => uint256) public actionNonces; // текущий nonce игрока
function executeAction(
GameAction calldata action,
bytes calldata serverSignature
) external {
// Проверяем подпись game server
bytes32 digest = _hashTypedData(action);
address signer = ECDSA.recover(digest, serverSignature);
require(signer == GAME_SERVER_SIGNER, "Invalid signature");
// Проверяем nonce (защита от replay)
require(action.nonce == actionNonces[msg.sender], "Invalid nonce");
actionNonces[msg.sender]++;
// Проверяем deadline
require(block.timestamp <= action.deadline, "Expired");
// Списываем энергию
spendEnergy(action.characterId, action.energyCost);
_processAction(action);
}
Cooldown механизм
Некоторые действия требуют cooldown независимо от энергии:
mapping(uint256 => mapping(uint8 => uint256)) public lastActionTime; // charId → actionType → timestamp
uint256 public constant BOSS_FIGHT_COOLDOWN = 4 hours;
modifier withCooldown(uint256 charId, uint8 actionType, uint256 cooldown) {
require(
block.timestamp >= lastActionTime[charId][actionType] + cooldown,
"Action on cooldown"
);
_;
lastActionTime[charId][actionType] = block.timestamp;
}
function fightBoss(uint256 charId) external withCooldown(charId, ACTION_BOSS, BOSS_FIGHT_COOLDOWN) {
spendEnergy(charId, BOSS_FIGHT_ENERGY_COST);
// ...
}
Экономическая модель: sink и source
Энергетическая система работает как регулятор экономики. Важно балансировать:
Sources (откуда берётся энергия):
- Regen over time (бесплатно, ограничено max)
- Purchase за game token (sink для токена)
- Staking NFT более высокого tier → бонусный regen
- Daily login reward (один раз в 24ч)
Sinks (куда уходит энергия):
- Боевые действия
- Фарминг ресурсов
- Крафт предметов
- PvP ставки
Если sources >> sinks — инфляция энергии, игра теряет смысл. Если sinks >> sources — игроки уходят из-за невозможности играть без постоянных платежей.
| Параметр | Рекомендации |
|---|---|
| Regen rate | Заполнение с 0 до max за 8–12 часов |
| Max energy | 1–3 игровых сессии по 2–3 часа |
| Premium refill | Не более 2–3 полных refill в день |
| Tier multiplier | Max 2x–3x, не больше |
Стек и интеграция
Контракты: Solidity 0.8.x, packed structs (uint128/uint64/uint8 для экономии storage). Foundry для тестов с vm.warp() для проверки regen логики.
// Foundry тест regen механики
function test_energyRegenOverTime() public {
uint256 tokenId = 1;
// Тратим всю энергию
vm.prank(player);
game.spendAllEnergy(tokenId);
assertEq(energy.currentEnergy(tokenId), 0);
// Пропускаем 50 секунд
vm.warp(block.timestamp + 50);
// Проверяем regen (tier 1: 1 ед/сек)
assertEq(energy.currentEnergy(tokenId), 50e18);
// Пропускаем ещё 200 секунд — должны упереться в max (100)
vm.warp(block.timestamp + 200);
assertEq(energy.currentEnergy(tokenId), 100e18); // capped at max
}
Ориентиры по срокам
Базовая система (lazy regen, spend на действия, cooldowns): 2–3 недели. Полная система (tier-based regen, ERC-20 energy token, DEX интеграция, anti-cheat signed actions, dashboard аналитики): 5–7 недель.







