BlockCraft Contract Design
We love the idea of fully-onchain-game, so we decided to build one. First of all, we hope it is EVM compatible, because of strong bases of ethereum ecosystem; Second, the genre of SLG(Simulation Game) would be perfect for current phase when TPS is still low; Third, it’s great to make smart contract scalability but not upgradable, in honor of blockchain spirit of immutability and decentralizition.
Of course, everything should be on chain involving reward module, producing module, fighting module and so on.
Main Ingridients of Game
Hero
Hero is main character of game, which have four profession including warrior, archer, mage and alchemist, fore three is fighting character and last one can only be used as a miner in workshop to mine coin — a ERC20 token used as game circulating currency. Hero can be upgraded in level and star , and furthermore armed with a equipment, to enhance attributes like intellactual or strength.
/// @title The interface for a BlockCraft hero
/// @notice A hero is the core asset in the BlockCraft and it's prerequisite for playing game
interface IHero is IERC721 {
/// @dev hero's basic attributions, never change
struct HeroBasicAttr {
Profession profession;
// revealed afterward
Races race;
// the hero's rarity which depends on sum of all attributions
Rarity rarity;
// the hero's 6 basic attributes
uint16 strength;
uint16 patience;
uint16 willpower;
uint16 agility;
uint16 intellect;
uint16 spirit;
}
/// @dev hero's dynamic information which changes according to game progress
struct HeroInfo {
/// @dev 0x01 arm, 0x02 world, 0x04 yield, 0x08 staked
/// @notice hero can be transfered only when status is zero
uint8 status;
// the hero's grade level which maximum is 20
uint8 level;
// the hero's star level which maximum is 8
uint8 star;
// how many years a hero can work in yielding system
uint8 life;
// how many soldiers a hero can lead. it can be calculate by level
// and also it affected by waring gear
uint16 leadership;
// the current energy left for a hero, the maximum is 43200, pricision 10000
uint32 energy;
// the current health point left for a hero
uint16 health;
// usually full health point equals 5 times of patience
// but gear's health point can increase this attribution
uint16 fullHP;
// hero automatically recover health point from this time
uint32 healStartTime;
// reserved for special abilities or special moves
uint32 skill;
// reserved for unknow
uint64 feature;
}
}Macrocosm
As a big open world in blockcraft, macrocosm is a place where players can assign their hero to. In the macrocosm(some times we call it CraftWorld) heros can random picked to gather resources and fight each other(pvp, player vs player) or fight with random monster(pve, player vs enviroment) which result in winning coin or resources reward.
/// @title The interface for a BlockCraft World
/// @notice A hero explores world can get different kind of resources
contract CraftWorld {
/// @dev hero's basic information in the craftworld
struct WorldData {
// how many times hero was choosen
uint8 hitCount;
// how many times hero enter the craftworld in one day
uint8 exploreCount;
// which day is it today
uint16 keyDay;
}
/// @notice function call through dynamic dispatch based on probability
function drive(uint256 r, uint256 level, uint256 id) private {
uint256[13] memory p = [uint256(8), 16, 24, 32, 40, 48, 64, 72, 76, 79, 86, 93, 100];
uint256[13] memory types = [uint256(0x01), 0x02, 0x04, 0x03, 0x05, 0x06, 0x01, 0x03, 0x05, 0x07, 0x01, 0x02, 0x04];
uint256 i;
uint256 rand = r % 100;
while (i < 12 && rand >= p[i]) {
++i;
}
r = Random.next(r);
if(p[i] < 48) {
glean(r, level, id, types[i]);
} else if(p[i] < 79) {
PvEFight(r, level, id, types[i]);
} else {
gather(r, level, id, types[i]);
}
}
}
Production
Resouces — including wood, stone, iron and coin — can be used to build buildings, and hero can be assigned to producing resources, denpending on their profession and main attribute. Upgrading hero’s star number can enhance yielding rate of resouces.
/// @title The interface for hero and yielding system
/// @notice A hero can yield token or resources when working in building
interface IYieldSystem {
/// @notice emitted when hero was assigned to yield in a building
/// @param heroId hero's token id
/// @param bldId building id
/// @param yieldStart when start to calculae production
event AssignHero(uint256 indexed heroId, uint256 indexed bldId, uint256 yieldStart, uint256 yieldRate);
event UnsssignHero(uint256 indexed heroId);
/// @notice emitted when recalculating production
/// @param heroId hero's token id
/// @param amount how much token was yieled since last start time
/// @param yieldStart when start to calculae next around production; zero means hero stop yielding
event Claim(uint256 indexed heroId, uint256 amount, uint256 yieldStart);
struct YieldInfo {
IBuilding.BLDType bldType;
uint32 bldId;
uint16 startBLDLevel;
// the time when hero start working, zero means not working
uint32 yieldStart;
// yield amount per second
uint96 yieldRate;
// fractional amount means less than 1 ether(only for ERC1155)
uint64 fractionalAmount;
}
}Architecture
Conceptually, we split smart contract(solidity) into 4 layers — entrypoint layer, game-scene layer, manager/system layer, basic assets layer — from top to buttom.
Entry Point
The entry point play two roles:
- executing a batch of operations in one transaction
And player can assemble multi-operations — for example level-up hero, assign hero to yield, wearing equipment — into one transaction using operation cart (similar to shopping cart in e-commerce webite) with only once popping up wallet.
2. forcing every transaction to drive the Macrocosm foward
Transaction is basic driving force for block chain, without it state machine would stop. Similarly, we hope every transaction interact with BlockCraft to dirve Macrocosm. It means player’s hero can be automatched or autoselected to execute task, owner do not have to create transaction. Everyone’s transaction is for everyone.
Game Sence
This layer is resposible for core game business and logic, for now it include Macrocosm and Inner City. Like we said before, Macrocosm was drived by every single transaction. As every time driving Macrocosm, random selected heros was assigned 3 different task: gathering, gleaning or fighting. Depending on random number and result of fighting, selected hero would receive some reward.
Manager and System
Manager and system layer can manipulate multiple underlying contract. For example, when upgrading hero level, player needs modify hero contract(ERC721) and resources contract state as this process consume experience potion which is a ERC1155 token. So when it comes to manager, contract don’t have state but have business logic such as HeroManager contract involving upgrade, healing, replenish etc, but do not store any state.
Get Longmaosen’s stories in your inbox
Join Medium for free to get updates from this writer.
Just like manager, system can deal with multiple contract. But system also can hold states, for example, ArmSystem holds the relationship hero and equipment but also includes logic about how wear and unwear a gear of weapon.
Basic Assets
Hero and Gear contract are standard ERC721 with some extra attributions, resource contract is a ERC1155, and BCToken is ERC20, building contract is ERC721 but can not transfer, named SBT(soul bounding token);
Design Principle
Contracts can only call each other in one direction from top to buttom, so bilateral dependency is not allowed. When it comes to layer, not strictly, lower layer contract can only be called from higher layer contract. As access control, a game player can only send transactions to entrypoint, and entrypoint dispatch it to game scene or manager and system, which endowed with right to call asset contracts.
Operation Cart
It is kind of annoying when every move or instruction in game play, such as recovering hero’s health point or assigning hero to work, need to pop up wallet and confirm transaction. So we create a operation cart which players can add all kind of operation to. Once user get operation done, he can submit operation-list in cart and confirm transaction on wallet to broadcast a transaction to blockchain, technically entrypoint contract.
It hugely decrease the frequency of interacting with cryptowallet and blockchain. So players can more indulge in game itself instead of wallect or smart contract.
Frontend simply populated the call into a abi-encoded calldata, and then put all calldata into a array, which is the paramether of executeBatch method in EntryPoint contract.
contracts.batchParam = function (address, data) {
const target = [];
target.push(address);
const calldata = [];
calldata.push(data);
const param = { target: target, calldata: calldata }
return param
}
hero.heal = async function (id) {
const iHeroManager = await createContractWithKey("HeroManager");
const method = await iHeroManager.populateTransaction.heal(id)
let param = c.batchParam(iHeroManager.address, method.data)
const iEntryPoint = await createContractWithKey("EntryPoint");
let tx = await iEntryPoint.executeBatch(param.target, param.calldata);
console.log(`hero ${id} heal tx.hash: ${tx.hash}`);
let receipt = await tx.wait()
console.log("##----------- heal hero events: -----------##")
console.log(receipt.events)
}Entry Point
We get inspiration from multicall and AA wallet, so we can pack a number of call into one transaction and dispatch them to different target contract. The only difference is we don’t want one of call affects others, when one call reverted or errored we simply emit the message, but do not revert whole transaction, and then subsequent other call can continue to execute.
In consideration of smart contract secure vulnerability, we force only EOA account can interact with entry point to avoid call from a unknown contract or to a risky external contract.
contract EntryPoint {
event CallFailMsg(address indexed target, bytes data, bytes message);
IWorld public craftWorld;
/// @notice execute a sequence of calls in a single transaction
function executeBatch(address[] calldata dest, bytes[] calldata func) external {
if(msg.sender != tx.origin) revert Error.OnlyEOAAllowed(msg.sender);
uint256 length = dest.length;
if(length != func.length) revert Error.ArrayLengthMismatch();
for (uint256 i; i < length; ++i) {
_callNoRevert(dest[i], 0, func[i]);
}
craftWorld.commit();
}
/// @notice we don't want different call effects each other, so there is no revert
function _callNoRevert(address target, uint256 value, bytes memory data) private {
(bool success, bytes memory result) = target.call{value : value}(data);
if (!success) {
emit CallFailMsg(target, data, result);
}
}
}Random Number
We didn’t use oracle to feed the random number to contract from offline, because it is kind of heavy. So we use on-chain random number based on pre-blockhash, timestamp, gasleft and some seed. It is not secure like oracle for miner or proposer can manipulate it, but we think it is good enough for now, and it was used by famous game WolfGame and and challengd by community.
However, unlike PoW concensus, PoS block interval are almost exactlly same, this make block timestamp predictable by everyone, not just proposer. As for gasleft, it is predictable too.
library Random {
/**
* generates a pseudorandom number inspired by wolfgame
* @param seed a value ensure different outcomes for different sources in the same block
* @return a pseudorandom value
*/
function random(uint256 seed) internal view returns (uint256) {
return uint256(keccak256(abi.encodePacked(
tx.origin,
blockhash(block.number - 1),
block.timestamp,
gasleft(),
seed
)));
}
/**
* generates a pseudorandom number with inline assembly, it's more gas efficient
* @param seed a value ensure different outcomes for different sources in the same block
* @return h a pseudorandom value
*/
function randAsm(uint256 seed) internal view returns (uint256) {
address sender = tx.origin;
bytes32 h;
assembly {
let start := mload(0x40)
let ptr := start
mstore(ptr, shl(96, sender))
ptr := add(ptr, 20)
mstore(ptr, blockhash(sub(number(), 1)))
ptr := add(ptr, 32)
mstore(ptr, timestamp())
ptr := add(ptr, 32)
mstore(ptr, gas())
ptr := add(ptr, 32)
mstore(ptr, seed)
h := keccak256(start, 0x94)
}
return uint256(h);
}
}PS: randAsm() can save half gas fee than random().
Finally, we prefer arbitrum, because their GasUsed is changing according to L1 GasPrice, leading to GasLeft become more random than others, moreover less than one second block time, make preblockhash and timestamp more valueable. For example, a regular ETH transfer gas-usage is fixed 21,000 in Ethereum, but is changing in Arbitrum, sometimes it more than 100,000.
Rarity Distribution
Hero have 6 basic attributions: strength, patience, willpower, agility, intellect, spirit. Accoding to hero profession, every attribute has maximum limited value, and single attribute is uniform distributed between one and maximum: [1, max].
There is 5 hero rarities with different probability including N(47.8%), R(40%), SR(10%), SSR(2%), UR(0.2%). And hero’s rarity depends on the sum of all his six attributes.
So we need randomly mint hero and generate their attibutes, and also make their rarity follows the above rules.
/// @title HeroLib
/// @dev a library for hero
library HeroLib {
using Strings for uint256;
/// @dev randomly generate hero's some attributes
/// @dev setting hero's some attributes to default value
/// @param id hero's token id
function randGenHero(uint256 id) internal view returns (IHero.HeroBasicAttr memory hba) {
uint256 r = Random.randAsm(id);
{
uint256 prof = Math.getLastNBits(r, 8);
uint256 p = prof % 10;
if (p < 3) {
hba.profession = IHero.Profession.WARRIOR;
} else if (p < 6) {
hba.profession = IHero.Profession.ARCHER;
} else if (p < 9){
hba.profession = IHero.Profession.MAGE;
} else {
hba.profession = IHero.Profession.ALCHEMIST;
}
}
{
uint256[6] memory rs = Math.getAttrRandom(r >> 8);
uint256[6][4] memory t = [
[uint256(90) , 100, 80, 70, 50, 60],
[uint256(90) , 70, 70, 100, 40, 80],
[uint256(50) , 80, 70, 60, 100, 90],
[uint256(40) , 90, 70, 70, 80, 100]
];
uint256 p = uint256(hba.profession);
hba.strength = uint16(rs[0] % t[p][0] + 1);
hba.patience = uint16(rs[1] % t[p][1] + 1);
hba.willpower = uint16(rs[2] % t[p][2] + 1);
hba.agility = uint16(rs[3] % t[p][3] + 1);
hba.intellect = uint16(rs[4] % t[p][4] + 1);
hba.spirit = uint16(rs[5] % t[p][5] + 1);
}
hba.rarity = calcRarity(hba);
}
/// @notice if every element is uniform then their sum is nearly normal distribution
function calcRarity(IHero.HeroBasicAttr memory hba) internal pure returns(IHero.Rarity rarity) {
uint256 sum = sumAttr(hba);
if(sum < 225) {
rarity = IHero.Rarity.N;
} else if(sum < 294) {
rarity = IHero.Rarity.R;
} else if(sum < 337) {
rarity = IHero.Rarity.SR;
} else if(sum < 376) {
rarity = IHero.Rarity.SSR;
} else {
rarity = IHero.Rarity.UR;
}
}
function sumAttr(IHero.HeroBasicAttr memory hba) internal pure returns(uint256) {
return (hba.strength + hba.patience + hba.willpower + hba.agility + hba.intellect + hba.spirit);
}
}Fighting System
Fighting process is turn-based. How much agility determine who is the first to move, and if it hited or not with some extra random element. When a hero or monster was attacked and hited, the damage would result in the decreasing health point, if health point lower than zero , hero or monster would be killed.
Contract FightLib {
function fight(IHero.FightInfo memory fight1, IHero.FightInfo memory fight2, uint256[16] memory ra)
internal
pure
returns(uint256 winner, uint256 damage1, uint256 damage2, uint256 packedFight)
{
for (uint256 i; i < FIGHT_ROUND; ++i) {
if ((ra[i] % 10000) < fight1.accuracy) {
damage2 += fight1.attack * 10 * ((ra[i] >> 8) % 7 + 5) / (fight2.defense + 100) + fight1.FixedDamage / 1000;
packedFight |= damage2 << (i * 32);
if (damage2 >= fight2.health) {
winner = 1;
break;
}
}
if ((ra[i + FIGHT_ROUND] % 10000) < fight2.accuracy) {
damage1 += fight2.attack * 10 * ((ra[i + FIGHT_ROUND] >> 8) % 7 + 5) / (fight1.defense + 100) + fight2.FixedDamage / 1000;
packedFight |= damage1 << (i * 32 + 16);
if (damage1 >= fight1.health) {
winner = 2;
break;
}
}
}
}
}The concrate damage depends on the peers attack and defense which calculated from basic attributes, and also, if there is countership between their profession, if they arm or not some equipment to enhance attributes.
Contract FightLib {
function countership(IHero.Profession p1, IHero.Profession p2) internal pure returns(bool) {
if(p1 == IHero.Profession.WARRIOR && p2 == IHero.Profession.ARCHER) {
return true;
} else if(p1 == IHero.Profession.ARCHER && p2 == IHero.Profession.MAGE) {
return true;
} else if(p1 == IHero.Profession.MAGE && p2 == IHero.Profession.WARRIOR) {
return true;
}
return false;
}
function calcSingleFightAttrWithArm(
IHero.FightAttr memory hero1,
IGear.GearBasicAttr memory gear1,
IHero.FightAttr memory hero2,
IGear.GearBasicAttr memory gear2
)
internal pure returns (IHero.FightInfo memory fight1)
{
uint256 common1 = 4 + hero1.star;
if(hero1.profession == IHero.Profession.MAGE) {
fight1.attack = hero1.intellect * common1 * 2 + gear1.MagicAttack / 1000;
} else {
fight1.attack = hero1.strength * common1 * 2 + gear1.PhysicalAttack / 1000;
}
if(countership(hero1.profession, hero2.profession)) {
fight1.attack = fight1.attack * 11 / 10;
}
if(gear1.AttackPowerBonusPercentage != 0) {
fight1.attack = fight1.attack + fight1.attack * gear1.AttackPowerBonusPercentage / 1000;
}
if(gear1.FixedDamage != 0) {
fight1.FixedDamage = fight1.FixedDamage + gear1.FixedDamage / 1000;
}
if(hero2.profession != IHero.Profession.MAGE) {
fight1.defense = hero1.willpower * common1 + gear1.PhysicalDefense / 1000;
} else {
fight1.defense = hero1.spirit * common1 + gear1.MagicDefense / 1000;
}
fight1.accuracy = 10000 * 2 * uint256(hero1.agility) / (2 * hero1.agility + hero2.agility) + 10 * gear1.Accuracy - 10 * gear2.EvasionRate;
fight1.health = hero1.health;
}
}winner can have coin and resources — such as wood, iron, stone, and expirience potion — rewarded.
BlockCraft still in testing, here is the app link.
We are going to open source code after lunching.





