How We Built This - Treaty Technical Overview
By Yijia Chen
Happy New Year! In the last article we briefly discussed the high level of treaties, the flexible on-chain social contracts. In this article, we will dive deeper into the smart contracts that power the system. Specifically, we’ll talk about the in-game data storage, token standards, 2 ways of permission mechanisms, and finally the treaty architecture.
At Curio, we’re excited about discovering new mechanisms to power on-chain games. We hope that the composability of the treaty framework, specifically the ability for players to express social relationships through code, can give rise to new mechanisms not seen regularly before.
Data
Understanding how in-game data is stored is essential for understanding the ownership and permission layers, and so let’s begin here.
ECS
The vast majority of in-game data is stored in Entity Component System (ECS), a framework quite common in traditional games. We’ve brought ECS into Solidity primarily for 2 purposes: 1) reducing technical overhead coordinating frontend, backend, and indexer, and 2) allowing developers to more easily build on top of the game. All in-game data in ECS will be shown in a game panel on the Treaty game client, which players are free to explore.
Let’s start with an entity. Every in-game “thing”, including Nation
(which is simply a player), Tile
, or Treaty
, is an entity, which is simply an increasing uint256
id for bookkeeping. A set of entities is kept in game storage at all times. Each entity has components, which are essentially data fields associated with the entity. Compared to entities which are created and destroyed on the fly, components are created together at the beginning of a game using the registerComponents
function in AdminFacet
, and the number of components remain fixed throughout a game.
To represent a tile, for instance, instead of storing it as a struct which would look like this
struct Tile {
bool canBattle;
Position startPosition;
uint256 terrain;
uint256 lastUpgraded;
uint256 lastRecovered;
uint256 nationID;
address addr;
uint256 level;
}
We store its fields as components in the following function in Templates.sol
:
function addTile(
Position memory _startPosition,
uint256 _terrain,
address _address
) public returns (uint256) {
uint256 tileID = ECSLib.addEntity();
ECSLib.setString("Tag", tileID, "Tile");
ECSLib.setBool("CanBattle", tileID);
ECSLib.setPosition("StartPosition", tileID, _startPosition);
ECSLib.setUint("Level", tileID, 1);
ECSLib.setUint("Terrain", tileID, _terrain);
ECSLib.setUint("LastUpgraded", tileID, 0);
ECSLib.setUint("LastRecovered", tileID, 0);
ECSLib.setUint("Nation", tileID, 0);
ECSLib.setAddress("Address", tileID, _address);
return tileID;
}
Each setter, defined in ECSLib
, finds the component contract corresponding to the component name, encodes the component value using abi.encode
, and updates the value corresponding to the specified entity. When fetching a component value of an entity, for example the Nation
this Tile
belongs to, you can simply call
uint256 nationID = ECSLib.getUint("Nation", tileID);
Then conversely, to get all entities whose Nation
component is set to a specific nationID
value, you would call
uint256 nationEntities = ECSLib.getUintComponent("Nation").getEntitiesWithValue(nationID);
How is this achieved without looping through all the entities with a Nation
component? Let’s take a look at components under the hood. Every component extends the Component
contract, which has the following two global variables:
mapping(uint256 => bytes) private entityToValueMap; // entity => value of entity component
mapping(uint256 => address) private valueToEntitySetAddrMap; // value => address of set of entities with this component value
These two are essentially double mappings of entities and components. On top of Component
we’ve also added typed components such as AddressComponent
and UintComponent
to save a step in abi encoding and decoding, so that one can call getUint(<component-name>, <entity>)
instead of abi.decode(getBytesValue(<component-name>, <entity>), (uint256))
for a getter and similarly for a setter.
This explains how fetching all entities with a specific component value is achieved. But what if I wish to get all entities with specific values in two components or more? We’ve added ECS-based queries which are very useful for these situations. Below is the query in GameLib
for performing the task.
function getNationArmies(uint256 _nationID) internal view returns (uint256[] memory) {
QueryCondition[] memory query = new QueryCondition[](2);
query[0] = ECSLib.queryChunk(QueryType.IsExactly, Component(gs().components["Nation"]), abi.encode(_nationID));
query[1] = ECSLib.queryChunk(QueryType.IsExactly, Component(gs().components["Tag"]), abi.encode("Army"));
return ECSLib.query(query);
}
The QueryCondition
struct takes in three parameters: the type of query, the component of interest, and the desired value. Currently our ECS engine supports 4 types of queries: Has
, HasNot
, IsExactly
, and IsNot
. The former two gets entities with or without a specific component, and thus the desired value field can be set to anything. The latter two gets entities based on whether the entity’s component value is equal to / unequal to the desired value. In the above query, the two conditions are taking entities whose “Nation
component value is equal to _nationID
” and whose “Tag
component value is equal to ‘Army’ ”. Our engine then executes the query by essentially taking intersections of the respective result sets from the conditions, albeit in a more gas-optimized way.
Game Constants
A special case of ECS is the game constants, for example the Food production rate of a Level 5 Farm or the Gold cost to upgrade a Level 3 Capital. Compared to other data, these constants are immutable (no writes needed) and are always fetched individually (no queries needed), and so they can be stored in a more read-optimized way. After we generate these constants off-chain in Python, we store them with a unique Tag
component following a “subject-object-componentName-functionName-level” convention. For example, “Capital-Gold-Cost-Upgrade-3” represents the Gold cost to upgrade a Level 3 Capital. The object or function name can be left blank if irrelevant, and the level can be set to zero. The full list of constants can be found in game_parameters.json
in our upcoming contract code release.
The only data source outside of ECS is GameState
written in Types.sol
. This struct stores a few game-level information such as gameInitTimestamp
and worldConstants
, which are determined at initialization, as well as entities, components, templates, and tokens which are above the ECS architecture.
Contract Basics
Let’s now examine Treaty’s smart contracts.
Most of the game logic functions, such as how players use their armies and resources, are bundled together to use one contract address thanks to the Diamond standard (EIP-2535). GameFacet
contains all player actions such as harvestResource
or upgradeTile
; AdminFacet
contains functions which can only be called by the game deployer or an authorized non-core contract; GetterFacet
provides the getters for non-core contracts and frontend. These core contracts share a few libraries such as GameLib
and ECSLib
. You can broadly think of the functions in these facets as directly belonging to Treaty the game, whether it’s for initialization, fetching data, or players taking actions.
Then, the token standards, treaty templates, smart contract wallets, custom treaties, as well as ECS standards all exist independently from, and are used by, the Diamond game contracts. They each have their own contract addresses. Since they don’t have access to libraries such as GameLib
, they rely on GetterFacet
for reads and generally AdminFacet
for writes, with the exception of treaties which can potentially call GameFacet
functions on behalf of players. Don’t worry if not everything about these contracts are clear yet; we will elaborate on what they are and why we have them in the rest of the article.
Ownership
Under the 4X game core, we’ve designed Treaty to have resources (e.g. Gold) and troops (e.g. Horseman), and have created Template
entities at the beginning of a game to store properties of resource and troop types. We wish these templates to have following desired behaviors: They can all be produced or destroyed under the game’s constraints, and they can often be transferred not just between players but also between different entities of a player, for example from a capital to an army or vice versa. Each owner, such as an army, can also impose its own rules on dealing with such an entity.
How do we represent these behaviors in a way most compatible with existing infrastructure and mainstream UX in crypto? We’ve decided to implement our custom ERC-20 standard, CurioERC20
, along with smart contract and burner wallets. Template entities such as Gold and Horseman are made to be ERC-20s with balanceOf
, transfer
, and transferFrom
which players can interact with. Each entity which can hold these tokens are either a smart contract wallet or an EOA based on whether they need to call functions. For example, an Army
is a smart contract wallet as it needs to call approve
for transfers, whereas a Tile
is simply a burner wallet. Both of these account types have an address, which is stored in the Address
component of the corresponding entity.
To be clear, what we mean here by a smart contract wallet is different from what’s commonly referred to as such. In Treaty, a smart contract wallet is simply a deployed instance of CurioWallet
, which has a function executeTx
:
function executeTx(address _contractAddress, bytes memory _callData) public onlyGameOrOwner returns (bytes memory) {
(bool success, bytes memory returnData) = _contractAddress.call(_callData);
require(success, string(returnData));
return returnData;
}
The function allows an owner of an army or capital to call functions on behalf of the owned entity, for example calling approve
for Gold ERC20 before calling transferFrom
.
Let’s take a look at transferHelper
under the hood. This is the function called by transfer
as well as transferFrom
after its allowance check.
function _transferHelper(
address _from,
address _to,
uint256 _amount
) private {
(uint256 senderInventoryID, , uint256 senderBalance) = _getInventoryIDLoadAndBalance(_from);
(uint256 recipientInventoryID, uint256 recipientLoad, uint256 recipientBalance) = _getInventoryIDLoadAndBalance(_to);
require(senderInventoryID != NULL && recipientInventoryID != NULL, "CurioERC20: In-game inventory not found");
require(senderBalance >= _amount, "CurioERC20: Sender insufficent balance");
require(getter.getDistanceByAddresses(_from, _to) <= maxTransferDistance, "CurioERC20: Too far from recipient to transfer");
uint256 transferAmount;
if (recipientBalance + _amount <= recipientLoad) {
transferAmount = _amount;
} else {
transferAmount = recipientLoad - recipientBalance;
}
admin.updateInventoryAmount(senderInventoryID, senderBalance - transferAmount);
admin.updateInventoryAmount(recipientInventoryID, recipientBalance + transferAmount);
emit Transfer(_from, _to, transferAmount);
}
Let’s examine this function closely. The addresses _from
and _to
each has its corresponding entity. They can be a Capital
, Army
, Tile
, or Treaty
— for these types of entities we’ve added a CanHoldTokens
boolean component to tag them, and refer to them as “keepers”.
(uint256 senderInventoryID, , uint256 senderBalance) = _getInventoryIDLoadAndBalance(_from);
(uint256 recipientInventoryID, uint256 recipientLoad, uint256 recipientBalance) = _getInventoryIDLoadAndBalance(_to);
The first two lines fetch the inventory IDs, loads, and current balances of the sender and recipient. To specify a few things here: An Inventory
entity exists for every keeper-token pair to track its balance in its Amount
component. (Although it’s not shown here, allowance is stored similarly to balance as an Allowance
entity for every keeper-token pair.) Load
is a uint256 component which specifies the maximum capacity of each keeper-token pair.
require(senderInventoryID != NULL && recipientInventoryID != NULL, "CurioERC20: In-game inventory not found");
require(senderBalance >= _amount, "CurioERC20: Sender insufficent balance");
require(getter.getDistanceByAddresses(_from, _to) <= maxTransferDistance, "CurioERC20: Too far from recipient to transfer");
The next three lines are require
statements which represent both standard constraints (2nd) and game-specific constraints (1st and 3rd). Specifically, we’ve implemented the 3rd as a proximity-based transfer constraint which blindly applies to all tokens in Treaty. This is a primitive example of location-based transfer which represents a context-based world like a game or the real world, and is a concept we’re very excited about.
The remainder of the function computes the actual transfer amount based on the recipient inventory load, and updates the inventories of the sender and recipient accordingly.
Permissioning
In Treaty, a player cannot create gold out of thin air, but she can delegate the gold one owns to another player for collective defense or to a yield farming pool. In other words, existence rules are defined by the game, yet relationships are dictated by players. Our goal is that any player of the game can manage who gets to do certain things on behalf of the player, in exactly what context, as flexibly as possible. We call this “permissioning”.
Since actions such as transferring gold or producing horsemen can all be performed with game functions in GameFacet
, the permissioning in Treaty comes down to the permissioning of those game functions.
Let’s look at a snippet from an example player function, move
:
function move(uint256 _armyID, Position memory _targetPosition) external {
...
uint256 nationID = ECSLib.getUint("Nation", _armyID);
uint256 callerID = GameLib.getEntityByAddress(msg.sender);
GameLib.nationDelegationCheck("Move", nationID, callerID, _armyID);
GameLib.treatyApprovalCheck("Move", nationID, abi.encode(callerID, _armyID, _targetPosition));
...
}
In the above snippet, there are two types of permissioning — player delegation and treaty approval. We shall elaborate what each one does below.
Player Delegation
In all player functions, the first parameter is the subject of the call. For example, move
has _armyID
as its first parameter; organizeArmy
has _capitalID
. By default, only the owner of the subject can call the function. However, players can delegate the right to call a function to another player, a treaty, or some other entity by calling delegateGameFunction
, which itself is a game function. Using this function, player A may delegate to player B the right to call a function (e.g. move
) either for a specific subject (e.g. an army) he owns, by calling
game.delegateGameFunction(playerAID, "Move", playerBID, delegatedArmyID, true);
Afterwards player B can call move
with _armyID
set to delegatedArmyID
without a problem.
Delegation can be useful also for treaties. For example, the sample Alliance treaty delegates the battle
function on behalf of every member upon joining. Note that the _subjectID
field is set to 0 — this means that the function is delegated for all subjects, existing currently or in the future.
function treatyJoin() public override {
...
// Delegate Battle function for all armies
treatyDelegateGameFunction("Battle", 0, true);
...
}
With this delegation, the following treatyBesiege
logic is made possible, specially the underlined line game.battle(...)
.
/**
* @dev Battle a target army belonging to a non-ally nation with all nearby ally armies.
* @param _targetArmyID target army entity
*/
function treatyBesiege(uint256 _targetArmyID) public onlySigner {
...
// Attack target army with ally armies in range
for (uint256 i; i < nearbyTilePositions.length; i++) {
uint256[] memory armyIDs = getter.getArmiesAtTile(nearbyTilePositions[i]);
for (uint256 j; j < armyIDs.length; j++) {
uint256 armyNationID = getter.getNation(armyIDs[j]);
if (getter.getNationTreatySignature(armyNationID, treatyID) != 0) {
// Use army to battle target army
game.battle(armyIDs[j], _targetArmyID);
...
}
}
}
}
Treaty Approval
There’s a feature which pure delegation of functions cannot achieve: treaties should be able to disallow calling a function for its members. For example, an alliance treaty can prohibit players from battling against other alliance members. This is done with the treaty approval functions.
In the CurioTreaty
contract extended by all treaties, for every player function in GameFacet
, there is an approval function named “approve<game-function-name>”. For example, the approveBattle
function in a treaty dictates whether members of a treaty can call battle
on specific parameters. Each approval function returns a boolean, which defaults to be true but which every treaty can override to give different results with different scenarios. When a player calls battle
, the following line in the battle
function automatically checks that, given the specific army entity and target entity, every treaty which the player is a member of approves the call — one disapproval is enough to revert it.
GameLib.treatyApprovalCheck("Battle", nationID, abi.encode(callerID, _armyID, _targetID));
This makes sense, as breaking one law while obeying all the others is still called breaking the law.
Let’s look at a specific approveBattle
function in our sample Alliance treaty, which disapproves if and only if a player attempts to battle their ally, and break this down.
function approveBattle(uint256 _nationID, bytes memory _encodedParams) public view override returns (bool) {
// Disapprove if target nation is an ally
(, , uint256 targetID) = abi.decode(_encodedParams, (uint256, uint256, uint256));
uint256 targetNationID = getter.getNation(targetID);
uint256 treatyID = getter.getEntityByAddress(address(this));
if (getter.getNationTreatySignature(targetNationID, treatyID) != 0) return false;
return super.approveBattle(_nationID, _encodedParams);
}
The first line decodes the function-specific parameters and puts targetID
in memory as the only one we need in this context. The second and third lines fetch the nation which the target belongs to, whether it’s an army or a tile, and the entity of the current treaty. Then, the fourth line checks whether the target nation has signed the treaty, and if so returns false
. Otherwise, it proceeds to the inherited approveBattle
function, which would return true
.
With both player delegation and treaty approval, you can imagine a complex combination of access permissioning possible between players or with treaties. Let’s now dive into the full alliance treaty.
Full Alliance Treaty
With all the moving pieces explained, here’s the full sample Alliance treaty. First, it imports the needed standards, facets, and structs, and constructs the treaty with its name and description:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {CurioTreaty} from "contracts/standards/CurioTreaty.sol";
import {GetterFacet} from "contracts/facets/GetterFacet.sol";
import {CurioERC20} from "contracts/standards/CurioERC20.sol";
import {Position} from "contracts/libraries/Types.sol";
import {console} from "forge-std/console.sol";
contract Alliance is CurioTreaty {
CurioERC20 public goldToken;
constructor(address _diamond) CurioTreaty(_diamond) {
goldToken = getter.getTokenContract("Gold");
name = "Alliance";
description = "A treaty between two or more countries to work together towards a common goal or to defend each other in the case of external aggression";
}
...
The treatyJoin
function is a standard function for every treaty provided in the CurioTreaty
base contract. Here, the function is overridden and transfers 1000 Gold from the player’s capital to the treaty as the membership fee, and delegates battle
for all the player’s armies. Then, it calls super.treatyJoin()
which registers the Signatory
entity in ECS as proof of membership.
...
function treatyJoin() public override {
// Transfer 1000 gold from nation to treaty
address nationCapitalAddress = getter.getAddress(getter.getCapital(getter.getEntityByAddress(msg.sender)));
goldToken.transferFrom(nationCapitalAddress, address(this), 1000);
// Delegate Battle function for all armies
treatyDelegateGameFunction("Battle", 0, true);
super.treatyJoin();
}
...
The treatyLeave
function, another standard function we provide, checks that the player has stayed in the alliance for at least 10 seconds, and then transfers the Gold membership fee back and also undelegates the battle
function. Finally, it calls super.treatyLeave()
which removes the Signatory
entity:
...
function treatyLeave() public override {
// Check if nation has stayed in alliance for at least 10 seconds
uint256 nationID = getter.getEntityByAddress(msg.sender);
uint256 treatyID = getter.getEntityByAddress(address(this));
uint256 nationJoinTime = abi.decode(getter.getComponent("InitTimestamp").getBytesValue(getter.getNationTreatySignature(nationID, treatyID)), (uint256));
require(block.timestamp - nationJoinTime >= 10, "Alliance: Nation must stay for at least 10 seconds");
// Transfer 1000 gold from treaty back to nation
address nationCapitalAddress = getter.getAddress(getter.getCapital(nationID));
goldToken.transfer(nationCapitalAddress, 1000);
// Undelegate Battle function for all armies
treatyDelegateGameFunction("Battle", 0, false);
super.treatyLeave();
}
...
The besiege function is a bit more complex. First, it verifies that the nation which owns the target army has not signed the treaty, and so is not an ally. Then, it fetches the 9-tile region around the target army. It loops through the tile positions, fetches the armies on them, and for those armies which belong to ally nations, the function calls battle
on behalf of them against the target army.
...
/**
* @dev Battle a target army belonging to a non-ally nation with all nearby ally armies.
* @param _targetArmyID target army entity
*/
function treatyBesiege(uint256 _targetArmyID) public onlySigner {
// Check if target army is in a non-ally nation
uint256 targetNationID = getter.getNation(_targetArmyID);
uint256 treatyID = getter.getEntityByAddress(address(this));
require(getter.getNationTreatySignature(targetNationID, treatyID) == 0, "Alliance: Cannot besiege army of ally nation");
// Get tiles belonging to the 9-tile region around the target army
// TODO: Need to be updated if attackRange is increased to above tileWidth
Position[] memory nearbyTilePositions = getter.getTileRegionTilePositions(getter.getPositionExternal("StartPosition", _targetArmyID));
// Attack target army with ally armies in range
for (uint256 i; i < nearbyTilePositions.length; i++) {
uint256[] memory armyIDs = getter.getArmiesAtTile(nearbyTilePositions[i]);
for (uint256 j; j < armyIDs.length; j++) {
uint256 armyNationID = getter.getNation(armyIDs[j]);
if (getter.getNationTreatySignature(armyNationID, treatyID) != 0) {
// Use army to battle target army
game.battle(armyIDs[j], _targetArmyID);
// Return early if target army is dead
if (getter.getNation(_targetArmyID) == 0) return;
}
}
}
}
...
Lastly, the approveBattle
function disapproves members from attacking entities belonging to other members, as explained before.
...
function approveBattle(uint256 _nationID, bytes memory _encodedParams) public view override returns (bool) {
// Disapprove if target nation is an ally
(, , uint256 targetID) = abi.decode(_encodedParams, (uint256, uint256, uint256));
uint256 targetNationID = getter.getNation(targetID);
uint256 treatyID = getter.getEntityByAddress(address(this));
if (getter.getNationTreatySignature(targetNationID, treatyID) != 0) return false;
return super.approveBattle(_nationID, _encodedParams);
}
}
The full contract is here:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {CurioTreaty} from "contracts/standards/CurioTreaty.sol";
import {GetterFacet} from "contracts/facets/GetterFacet.sol";
import {CurioERC20} from "contracts/standards/CurioERC20.sol";
import {Position} from "contracts/libraries/Types.sol";
import {console} from "forge-std/console.sol";
contract Alliance is CurioTreaty {
CurioERC20 public goldToken;
constructor(address _diamond) CurioTreaty(_diamond) {
goldToken = getter.getTokenContract("Gold");
name = "Alliance";
description = "A treaty between two or more countries to work together towards a common goal or to defend each other in the case of external aggression";
}
function treatyJoin() public override {
// Transfer 1000 gold from nation to treaty
address nationCapitalAddress = getter.getAddress(getter.getCapital(getter.getEntityByAddress(msg.sender)));
goldToken.transferFrom(nationCapitalAddress, address(this), 1000);
// Delegate Battle function for all armies
treatyDelegateGameFunction("Battle", 0, true);
super.treatyJoin();
}
function treatyLeave() public override {
// Check if nation has stayed in alliance for at least 10 seconds
uint256 nationID = getter.getEntityByAddress(msg.sender);
uint256 treatyID = getter.getEntityByAddress(address(this));
uint256 nationJoinTime = abi.decode(getter.getComponent("InitTimestamp").getBytesValue(getter.getNationTreatySignature(nationID, treatyID)), (uint256));
require(block.timestamp - nationJoinTime >= 10, "Alliance: Nation must stay for at least 10 seconds");
// Transfer 1000 gold from treaty back to nation
address nationCapitalAddress = getter.getAddress(getter.getCapital(nationID));
goldToken.transfer(nationCapitalAddress, 1000);
// Undelegate Battle function for all armies
treatyDelegateGameFunction("Battle", 0, false);
super.treatyLeave();
}
/**
* @dev Battle a target army belonging to a non-ally nation with all nearby ally armies.
* @param _targetArmyID target army entity
*/
function treatyBesiege(uint256 _targetArmyID) public onlySigner {
// Check if target army is in a non-ally nation
uint256 targetNationID = getter.getNation(_targetArmyID);
uint256 treatyID = getter.getEntityByAddress(address(this));
require(getter.getNationTreatySignature(targetNationID, treatyID) == 0, "Alliance: Cannot besiege army of ally nation");
// Get tiles beloning to the 9-tile region around the target army
// Note: Need to be updated if attackRange is increased to above tileWidth
Position[] memory nearbyTilePositions = getter.getTileRegionTilePositions(getter.getPositionExternal("StartPosition", _targetArmyID));
// Attack target army with ally armies in range
for (uint256 i; i < nearbyTilePositions.length; i++) {
uint256[] memory armyIDs = getter.getArmiesAtTile(nearbyTilePositions[i]);
for (uint256 j; j < armyIDs.length; j++) {
uint256 armyNationID = getter.getNation(armyIDs[j]);
if (getter.getNationTreatySignature(armyNationID, treatyID) != 0) {
// Use army to battle target army
game.battle(armyIDs[j], _targetArmyID);
// Return early if target army is dead
if (getter.getNation(_targetArmyID) == 0) return;
}
}
}
}
function approveBattle(uint256 _nationID, bytes memory _encodedParams) public view override returns (bool) {
// Disapprove if target nation is an ally
(, , uint256 targetID) = abi.decode(_encodedParams, (uint256, uint256, uint256));
uint256 targetNationID = getter.getNation(targetID);
uint256 treatyID = getter.getEntityByAddress(address(this));
if (getter.getNationTreatySignature(targetNationID, treatyID) != 0) return false;
return super.approveBattle(_nationID, _encodedParams);
}
}
Hopefully things make more sense now! In this upcoming version we will have permissioned deployment of treaties just to make sure the concept works correctly. We will have a separate post walking through those as well. The full permissionless nature of treaties representing all in-game relationships is soon to come.
Next Steps
Treaty shows how social relationships can be encoded and more importantly enforced through code, which we think meaningfully expands the meta-game for 4x strategy games. As for what’s next, we’re working to tackle harder challenges for on-chain games. For example, what are the tradeoffs for asset security in an app-chain vs. other infrastructure models? How can we make treaties more versatile to accommodate crazier gameplay like letting players launch their own tokens, while maintaining a high degree of beginner friendliness? How heavy should the core-game loop be compared to the meta-game? If you’re interested in these challenges, we have open opportunities here.
Don’t forget to join our Discord and to join some treaties when the game launches. Happy 2023!