Building a Pay-to-Win Game Smart Contract on Conflux
Players pay CFX to buy “power.” More payment = more power. The contract tracks it all on-chain.
What is “pay to win”?
In a pay-to-win game, players can spend real money (or crypto) to get an advantage: stronger stats, faster progress, or items that free players can’t easily get. The economic rule is simple: pay more → get more power.
We can put that rule on a blockchain so that:
- Payments are transparent and trustless.
- No one can fake their power level; it’s stored on-chain.
- The game (or another contract) can read each player’s power for leaderboards or gameplay.
This post walks through a minimal PayToWinGame contract written in Solidity for Conflux eSpace (EVM-compatible). Players send CFX to buy power; the owner can change the price and withdraw collected funds.
Contract overview
The contract does three things:
-
Accepts CFX — Players call
buyPower()and send CFX. -
Converts payment to power —
power = msg.value / pricePerPower, capped per transaction. -
Tracks everything —
powerOf[player],totalPowerSold,totalCollected; owner canwithdraw()andsetPrice().
1. State and config
We store who owns the contract, the price per unit of power, a per-tx cap, and each player’s total power. We also keep global stats for transparency.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PayToWinGame {
address public owner;
/// Price per 1 power (in wei, e.g. 0.01 CFX = 1e16 wei per power)
uint256 public pricePerPower;
/// Max power a single purchase can add (cap per tx)
uint256 public maxPowerPerPurchase;
/// Player address => total power bought (cumulative)
mapping(address => uint256) public powerOf;
/// Total power ever sold (for stats)
uint256 public totalPowerSold;
/// Total CFX collected by the contract
uint256 public totalCollected;
event PowerPurchased(address indexed player, uint256 amountPaidWei, uint256 powerAdded);
event Withdrawn(address indexed to, uint256 amount);
event PriceUpdated(uint256 newPricePerPower, uint256 newMaxPowerPerPurchase);
error OnlyOwner();
error InvalidAmount();
error ZeroPower();
- owner — Can withdraw and update price.
-
pricePerPower — Wei required per 1 power (e.g.
1e16= 0.01 CFX per power). -
maxPowerPerPurchase — Max power granted in a single
buyPower()call. - powerOf[player] — Total power ever bought by that address.
- totalPowerSold / totalCollected — For stats and optional off-chain leaderboards.
2. Constructor and access control
The deployer becomes owner and sets the initial price and cap. Only the owner can call withdraw and setPrice.
modifier onlyOwner() {
if (msg.sender != owner) revert OnlyOwner();
_;
}
constructor(uint256 _pricePerPower, uint256 _maxPowerPerPurchase) {
owner = msg.sender;
pricePerPower = _pricePerPower; // e.g. 1e16 = 0.01 CFX per 1 power
maxPowerPerPurchase = _maxPowerPerPurchase; // e.g. 1000
}
Example: deploy with pricePerPower = 1e16 (0.01 CFX) and maxPowerPerPurchase = 1000 so each call gives at most 1000 power.
3. Core logic: buy power
Players send CFX to buyPower(). We compute how much power they get, cap it, then credit them and optionally refund excess.
function buyPower() external payable {
if (msg.value == 0) revert InvalidAmount();
uint256 power = msg.value / pricePerPower;
if (power == 0) revert ZeroPower();
if (power > maxPowerPerPurchase) {
power = maxPowerPerPurchase;
}
uint256 cost = power * pricePerPower;
if (cost < msg.value) {
// Refund excess (optional: could leave as donation)
(bool ok,) = msg.sender.call{ value: msg.value - cost }("");
require(ok, "Refund failed");
}
powerOf[msg.sender] += power;
totalPowerSold += power;
totalCollected += cost;
emit PowerPurchased(msg.sender, cost, power);
}
- power = msg.value / pricePerPower — Integer division; remainder is refunded or could be kept as donation.
-
Cap — If
power > maxPowerPerPurchase, we use the cap and refund the rest. -
Refund —
msg.value - costis sent back so the player only pays for the power they get. -
Updates —
powerOf[msg.sender]increases,totalPowerSoldandtotalCollectedincrease, and we emit an event for indexing.
4. Read functions
Anyone can read a player’s power or total power sold. Your game or a leaderboard can use these.
function getPower(address player) external view returns (uint256) {
return powerOf[player];
}
function getTotalPowerSold() external view returns (uint256) {
return totalPowerSold;
}
5. Owner: withdraw and set price
Only the owner can withdraw the contract balance and change the pricing parameters.
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
(bool ok,) = owner.call{ value: balance }("");
require(ok, "Withdraw failed");
emit Withdrawn(owner, balance);
}
function setPrice(uint256 _pricePerPower, uint256 _maxPowerPerPurchase) external onlyOwner {
pricePerPower = _pricePerPower;
maxPowerPerPurchase = _maxPowerPerPurchase;
emit PriceUpdated(_pricePerPower, _maxPowerPerPurchase);
}
receive() external payable {}
}
-
withdraw() — Sends the full contract balance to
owner. -
setPrice() — Updates
pricePerPowerandmaxPowerPerPurchasefor future purchases. -
receive() — Allows the contract to accept plain CFX transfers (e.g. for
buyPower()).
Flow in one picture
Player Contract
| |
| send CFX |
| buyPower() |
|------------------------>|
| | power = value / pricePerPower
| | powerOf[player] += power
| | (refund excess if any)
|<------------------------|
| (refund if any) |
| |
| getPower(player) |
|------------------------>|
|<------------------------| powerOf[player]
Example usage
-
Deploy:
PayToWinGame(1e16, 1000)→ 0.01 CFX per power, max 1000 power per tx. -
Player:
Sends 1 CFX tobuyPower()→ gets 100 power (1e18 / 1e16 = 100), rest refunded if any.
Sends 0.005 CFX → gets 0 power (integer division), so design yourpricePerPowerso small payments still give at least 1 power, or they’ll revert withZeroPower. -
Game / frontend:
CallgetPower(playerAddress)to show power or build a leaderboard; usePowerPurchasedevents for history. -
Owner:
Callwithdraw()to take collected CFX; callsetPrice(newPrice, newCap)to change economics.
Security notes
-
Refund — We use
call{ value }for the refund; in more complex contracts consider reentrancy guards (e.g. OpenZeppelin) and checks-effects-interactions. - Owner — Owner has full control over funds and pricing; use a multisig or timelock if the contract holds significant value.
-
Integer math —
power = msg.value / pricePerPowertruncates; smallmsg.valuecan yield 0 and revert withZeroPower. ChoosepricePerPoweraccordingly.
Deploy on Conflux eSpace
Use Hardhat (or your existing Conflux eSpace config). Example deploy script:
// scripts/deploy-paytowin.js
const pricePerPower = ethers.parseEther("0.01"); // 0.01 CFX per 1 power
const maxPowerPerPurchase = 1000n;
const Game = await ethers.getContractFactory("PayToWinGame");
const game = await Game.deploy(pricePerPower, maxPowerPerPurchase);
await game.waitForDeployment();
console.log("PayToWinGame at:", await game.getAddress());
Run against Conflux eSpace Testnet (Chain ID 71) so players can pay with testnet CFX.
Summary
| Piece | Role |
|---|---|
| buyPower() | Pay CFX → get power (capped, excess refunded). |
| powerOf[player] | Cumulative power per address (for leaderboards / game logic). |
| getPower / getTotalPowerSold | Read-only views for frontends. |
| withdraw() / setPrice() | Owner collects CFX and adjusts economics. |
| Events | PowerPurchased, Withdrawn, PriceUpdated for indexing and UX. |
GitHub; https://github.com/Vikash-8090-Yadav/playtowinconflux
Demo Video; https://youtu.be/vENAC0I9pag
This gives you a minimal, on-chain pay-to-win economy: pay CFX → get power → use it in your game or leaderboard. Deploy it on Conflux eSpace and plug your frontend or game backend into getPower() and the events.