Building On-chain games is very challenging, from the initial design to the unpredictable potholes during production and testing. In this series of articles, we hope to illuminate the dark path of on-chain games and create a knowledge base for developers, using reference implementation of some of the most talented builders in the space.
Today we will explore Dark Seas, created by 0xhank and presented on the 0xPARC Autonomous Worlds Residency. Dark Seas is a multiplayer strategy game in which each player has three ships. The game's goal is to protect your navy and destroy others.
SET UP
Download/clone the repo
Inside the project folder, run yarn&&yarn start
Start the client (Chrome is recommended)
Each captain controls three ships. The most important ship stats are HEALTH and CREW COUNT. If your ship runs out of HEALTH or CREW COUNT, it sinks.
Each round is divided into two phases: Move and Action. The movement works with commit reveal, so there is no temporal advantage. Generally, try to shoot your opponents and try to avoid taking special damage: such as leak, fire, or broken sails.
Dark Seas uses MUD as a framework. If you're not familiar, check our previous article. The key structure of MUD is the ECS (Entity Components System), which means a separation between data to logic. Data is stored in components, and the logic for changes in data represented in systems, each system and entity is a smart contract.
In dark seas, there are 7 systems and 21 components. To understand the core of the code, we'll focus on the 3 main systems, Action, Commit, and Move. Notice that there are other systems, the init and spawn systems, but they're much more intuitive and mainly set up the game.
The basic part of the moving system is the Move card. The Move card has three properties- direction, rotation, and length. The idea behind it is to create a set of movements with fixed properties that players can choose from each round. For example, the first move card has a 0 rotation and direction, meaning that if players choose it, their ship will move in a straight line. This creates a much more realistic type of ship movement that is bound to physical laws.
The moving system has writing access to 3 components, as you can see. Most of the logic is located on the libMove, its main function Moveship works as follows.
The functions gather information from a few components, a card that specifies a new move, the current position of the ship, and the last movement. After that, the new position is calculated by a few steps:
A new Move card is produced, taking into account the wind effect(which has a speed and direction that can change the direction move and range of motion;
The Move card is recalculated again with the position of the sails (full blown sails creates more robust move);
Then the new position is calculated (using LibVector), and the current rotation of the ship is updated.
The other set of mechanics the game has are actions the ship can take. Each turn player can choose to take a new action in each of their 3 ships.
You can affect the ship's movement with sail position (lower or higher sails), attack enemies' ships, and repair your ship's damage.
In the libAction we can see the attack function. The attack has a left, right, or front direction and works as follows:
The firing range of the attack is computed and represented by a polygon range that stretches from the ship initiating the attack;
the function fetches all the ship entities exists on the game;
Then checks for each enemy ship if it’s within the range of attack;
If it does, the damage is applied to the ship.
A leak, fire, broken mast, and sail can represent the damage of an attacked ship. Each of these components can be fixed through specified actions.
When the ship has a leak, it will have a component of the leak associated with the ship ID. Choosing to fix the leak as an action would result in the removal of the shipID from the leak component;
When the sail is broken, it has zero value and cannot be raised or lowered. Fixing the sail will initialize the sail position at 1;
Extinguishing the fire or repairing the mast requires 2 fixing actions and results in the removal of the ship from the OnFire or the BrokenMast component.
In order to create an equal game experience in which the ship’s movement is revealed to everyone at the same time, the game utilized a commit&reveal scheme. Before the ships move (the reveal phase), players have to commit to the movement they wish to perform.
First, players commit a hash in the next form:
uint256 commitment = uint256(keccak256(abi.encode(shipEntities, moveEntities, salt)));
The shipEntities is an array of the ships’ IDs & the moveEntities is an array of the moveCards for each ship. The salt is just a random number to make it harder to guess the input for the hash function.
The hash is sent in a transaction through the commit system, which sets the new commitment to the commitment component associated with the player entity;
Then when the player submits the move in the moving system(during the reveal phase), it’s required to follow the commitment they submitted earlier.
require(uint256(keccak256(arguments)) ==CommitmentComponent(getAddressById(components, CommitmentComponentID)).getValue(playerEntity), "MoveSystem: commitment doesn't match move."
On-chain games have unique challenges. One of them is time sync and coordination. Notice that every transaction sent to the blockchain is included in a certain block, that block has a time stamp. The interval of new block mining and the average time for transaction approval may vary between different chains, but usually, it can take up to a few minutes. On-chain games are still experimenting with different chains and roll-ups infrastructure but assume that we have a new block that is mined and approved every 1 sec. How would you sync it with front-end timing?
In Dark seas, we have 3 different phases- Commit, Reveal, Action.
which respectively last 30, 10, 30 seconds.
The immediate question is how to secure these intervals? In the sense of creating a standard time for all the players and also ensuring players would only do what is allowed and, for example, won’t take actions during the commit phase.
The first step is to include a time condition for every phase specific system,
we can see that the Commit system has that condition:
require(LibTurn.getCurrentPhase(components) == Phase.Commit, "CommitSystem: incorrect turn phase");
also, the move system:
require(LibTurn.getCurrentPhase(components) == Phase.Reveal, "MoveSystem: incorrect turn phase");
and the action system:
require (LibTurn.getCurrentPhase(components) == Phase.Action, "ActionSystem: incorrect turn phase");
→** Let’s head to libTurn:**
If we look for the getCurrentPhase() function, we can see it is a wrapper function for getPhaseAt()
function getCurrentPhase(IUint256Component components) internal view returns (Phase) {
return getPhaseAt(components, block.timestamp);
}
In which we input the current block time.
Next, we require the time input to be later than the start of the game (to prevent manipulation) after that we compute how much time passed since the game have begun, we then take the time of complete turn (100 sec) and check how much time passed into the turn (i.e in which phase we should be).
The vector library is another great example of less intuitive implementations for fundamental game mechanics. Smart contracts tend to have pretty abstract logic, mainly focusing on ownership, permissions management, and “legitimate” transfer of on-chain assets. It’s usually hard to find complex physical objects represented in smart contracts, and most of the advanced math is located in DeFi primitives.
In that sense, on-chain game logic is uncharted territory most of the fundamental building blocks of games, such as physical movement, vectors, and various algorithms such as for pathfinding (not to mention graphics), haven’t explored yet under the conditions and frameworks of smart contracts.
Dark Seas contains a library for dealing with the more complex geometric structures and basic movements. The library serves 2 main game mechanics:
Movement of ships
The logic for attack ranges
→ Let’s go to the LibVector:
The main function in the vector library is the getPositionByVector.
This function is used in the libMove to update the new position:
position = LibVector.getPositionByVector(position, rotation, moveCard.distance, moveCard.direction);
In general, the movement in the game is calculated using vectors meaning the position is updated according to the movement vector produced by the player moveCard like this:
As you can see, the move vector produces only the change in Coords, for example, -2 on the x plane and +3 on the y plane, such that if our ship’s initial position was 0,0, it would move to -2,3 in that way, it’s easier to create a consistent move that player can choose each turn.
Now let’s see how it’s implemented in solidity:
First, the args are the initial position and rotation of the ship (before the update of the last movement), the direction of the move card, and the distance, which is the vector’s magnitude.
First, the angle in its degree form gets converted to radian form. And then, we use the trigonometry library (you can check it here) to compute the sin and cos values of the angle. Notice that solidity doesn’t have decimals, so constants like PI are represented in an extended form. We take it into account and apply a final division by 10¹⁸ to create an appropriate new scaled coordinate.
The lib vector is used for the following functionalities:
Calculation of the ship’s bow and stern position;
Calculation of the firing range of the ship;
These two are then used to check which ships are within the firing range of the attacking ship.
As you can see, the bow is just the ship’s position and the stern is calculated by the main function of the libVector using a vector that extends from the position of the ship (the bow) to the stern, meaning a vector in the magnitude of the ship’s length (which is a constant initialized when the game begins).
The line connecting the bow and stern is the range of the ship’s objects.
Lets check how it’s implanted in the smart contracts:
and the GetSternLocation():
Each ship can initiate an attack to the right, left, or front side of it. The side attack range is represented by a trapezoid, and the front by triangle.
The trapezoid calculated by 4 coordinates:
The bow, stern, top corner range, and the bottom corner these coords computed in the same manner as earlier in the main lib vector function getFiringAreaSide is located here:
The front attack computed at similar manner just with 3 coords (top corner, bottom, ship position). Notice, to get these coords, We are using the range constant (vector magnitude) and angles respective to the site of the attack, which we input in the main function getPostionByvector().
Now, we got a firing range and found a way to represent ships as (connecting their bows to the sterns). We need to check which ships(lines) are crossing the range of the attacking ship.
→ Let’s head to libAction:
If we go to attackSide() we can see that all the ships get loaded into the function, and then for each ship, its bow(position) and stern get computed. After that, the function checks if the bow or the stern is within the attack range using withinPolygon4() function.
If we go back to the lib vector, we can see very clever implantation of the withinPolygon4():
The function uses a winding algorithm and returns True if the stern/bow is within the specified range.
Article written by:
Don’t miss upcoming articles: