Skip links

Æternity State Channels: A Peer-to-peer Browser Game

Original text by David Weil and Hernan Di Pietro

Introduction

Æternity is a promising blockchain platform with great potential for many application scopes. One such great feature is the native support for state channels.

In this article we will explore how we built a peer-to-peer browser game to explore this Æternity capability; along examine related features of the platform such as:

  • ForgAE and companion tools
  • The Sophia functional contract development language

Besides the technical aspects, Æternity has a vibrant, warm and helpful community.

To begin any development for the Æternity blockchain platform, you will need:

  • ForgAE: The framework for creating, deploying and testing AE smart contracts; it also provides the capability of running a local development node and compiler.
  • Aepp SDK: The set of interfaces to access the underlying node APIs. Several SDKs for Javascript, Python, Go and Elixir exist; however, we’ll refer to the Javascript one in this document. This SDK includes a webpack bundle to access the interfaces from the browser = environment.

ForgAE
https://github.com/aeternity/aepp-forgae-js

Aepp SDK (Javascript)
https://github.com/aeternity/aepp-sdk-js

Forum
https://forum.aeternity.com/

ADVICE
To have a better understanding of this guide, you should already grasp a general knowledge of blockchain technology and its associated terms and concepts.

Æternity State Channels

Top blockchain platforms such as Ethereum are known for having scalability problems. Several “Layer 2” solutions are being actively developed with uneven degrees of success, such as Perun, Celer, Raiden, etc. or off-chain generalized schemes such as Plasma. Those projects try to maximize transaction throughput by building off-chain layers on top -and leveraging- the base Ethereum chain qualities and features.

In short, users perform and accumulate state-changing transactions off-chain, reaching mutual agreement by signing the state transitions, and finally settling a final state on the blockchain which comprises all the state transitions done in the channel.

Æternity aims to provide a native implementation of state channels tightly integrated with the base blockchain features. For example, it’s possible to alter or verify state in channels by executing code; that is, Smart Contracts running off-chain, a feature that has been very hard to implement on top of Ethereum.

Complicated usability and no straightforward end-user experience have been some of the issues faced by blockchain projects and developer communities to achieve critical mass. Æternity objective is to also provide a solid platform for building user friendly mobile and web centric Dapps.

What we built

In this document, we will describe a game built upon the following specifications:

  • Typical Reversi rules implementation
  • Peer-to-peer communication through State Channels
  • A Smart Contract that keeps game state (board), performs validation and piece placement
  • Running in browser and featuring 3-D graphics
  • Using local Æternity development node and accounts

Before getting into the details, let’s run the game to see the bigger picture.

How to run the game

Software prerequisites

This guide was tested in Linux (Debian) and MacOS systems. Windows should require similar steps.

  1. Install ForgAE toolchain: npm install -g forgae
  2. Install Docker and Docker Composer. This is needed to run local nodes effortlessly. e.g: If you are running Debian, this will suffice: sudo apt-get install docker.io docker-compose
  3. Try to startup Æternity local nodes within a Docker container. Go to the root of the game project directory, where the docker-compose.yml file is located, and execute the following at your command prompt:forgae node This command will download the proper images for a container running three nodes and a local Sophia compiler right in your system. If your setup goes well, you will see an output indicating the proper funding of the default wallets/accounts included with the ForgAE local nodes:
===== Node was successfully started! =====
===== Funding default wallets! =====
     Miner ------------------------------------------------------------
public key: ak_2mwRmUeYmfuW93ti9HMSUJzCk1EYcQEfikVSzgo6k2VghsWhgU
private key: bb9f0b01c8c9553cfbaf7ef81a50f977b1326801ebf7294d1c2cbccdedf27476e9bbf604e611b5460a3b3999e9771b6f60417d73ce7c5519e12f7e127a1225ca
Wallet's balance is 871000000000000000000
#0 ------------------------------------------------------------
public key: ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk
private key: 7c6e602a94f30e4ea7edabe4376314f69ba7eaa2f355ecedb339df847b6f0d80575f81ffb0a297b7725dc671da0b1769b1fc5cbe45385c7b5ad1fc2eaf1d609d
Wallet's balance is 10000000000000000000
#1 ------------------------------------------------------------
public key: ak_tWZrf8ehmY7CyB1JAoBmWJEeThwWnDpU4NadUdzxVSbzDgKjP
private key: 7fa7934d142c8c1c944e1585ec700f671cbc71fb035dc9e54ee4fb880edfe8d974f58feba752ae0426ecbee3a31414d8e6b3335d64ec416f3e574e106c7e5412
Wallet's balance is 10000000000000000000
#2 ------------------------------------------------------------
public key: ak_FHZrEbRmanKUe9ECPXVNTLLpRP2SeQCLCT6Vnvs9JuVu78J7V
private key: 1509d7d0e113528528b7ce4bf72c3a027bcc98656e46ceafcfa63e56597ec0d8206ff07f99ea517b7a028da8884fb399a2e3f85792fe418966991ba09b192c91
Wallet's balance is 10000000000000000000
…
…
#9 ------------------------------------------------------------
public key: ak_zPoY7cSHy2wBKFsdWJGXM7LnSjVt6cn1TWBDdRBUMC7Tur2NQ
private key: 36595b50bf097cd19423c40ee66b117ed15fc5ec03d8676796bdf32bc8fe367d82517293a0f82362eb4f93d0de77af5724fba64cbcf55542328bc173dbe13d33
Wallet's balance is 10000000000000000000

We are ready to go!

WARNING Online and docker-image provided compiler versions may differ. At the time of this writing, online compiler was version 3.1.0, while docker image served V2.1.0 at port 3080. This must be considered carefully as some breaking changes -for example in supported address formats- were introduced in the transition from 2.X to 3.X compiler versions.

For current compiler versions and change logs, check (https://compiler.aepps.com/)

Æternity recommends to use a local compiler instead of relying upon online-compiler for production environment.

Game Files

The files needed to run the game are located under the aepp folder. They include:

  • aepp-sdk.browser* the Æternity application SDK bundle targeted at browser environments.
  • index.html the main HTML file for the game.
  • reversi-contract.js a Javascript file containing the contract source for compilation within the game (this must mirror contracts/Reversi.aes file in the root directory).
  • reversi-game.js the game code itself.

WARNING If you are going to launch the game from the filesystem directly (without a web server), you are probably going to need a browser extension to support CORS. For example, Firefox users can install CORS Everywhere (https://addons.mozilla.org/es/firefox/addon/cors-everywhere).

 

 

Open the index.html file in your browser. You should see the initial screen as follows:

State Channels have two sides; the initiator is the party who opens the channel and deploys the contract code in it. The other peer is called responder.
You may want to open two browser instances side by side to play.

Choose WHITE in one of your browser windows. It will connect to the node, and wait for the other party to join. Choose BLACK in the other window to join the channel.

If your node is responding and the peer-to-peer communication is working,

the WHITE player (the initiator) will compile and deploy the contract in the Smart Channel. This will take a moment. After this, the board will be setup, and game will ask the WHITE player to play. The BLACK player browser instance will wait for the rival to place a disc.

White Plays

From this point, the game will go forward, with both peers exchanging and signing state transitions which represent changes in the game board, until neither player has moves to make. In this moment, the game ends.

With this in perspective, let’s see the details.

State Channel Details

Channel Setup

Game Client and State Channel

In the state Channel both players are equal but there is a practical difference: one must initiate the state channel connection while the other must do its part accepting it, they are called initiator and responder respectively.

Opening the State Channel

Opening a State Channel is quite easy and it can be done in a few steps:

(1) You must open your connection to the Æternity node first. We do this in the setup_node() function:

async function setup_channel() {
if (is_initiator) {
    playerPair = {
        publicKey: "ak_2mwRmUeYmfuW93ti9HMSUJzCk1EYcQEfikVSzgo6k2VghsWhgU",
        secretKey: "bb9f0b01c8c9553cfbaf7ef81a50f977b1326801ebf7294d1c2cbccdedf27476e9bbf604e611b5460a3b3999e9771b6f60417d73ce7c5519e12f7e127a1225ca"
    };
    opposite = "ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk";
    role = "initiator";
} else {
    playerPair = {
        publicKey: "ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk",
        secretKey: "7c6e602a94f30e4ea7edabe4376314f69ba7eaa2f355ecedb339df847b6f0d80575f81ffb0a297b7725dc671da0b1769b1fc5cbe45385c7b5ad1fc2eaf1d609d"
    };
    opposite = "ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk";
    role = "responder";
}

i_addr = "ak_2mwRmUeYmfuW93ti9HMSUJzCk1EYcQEfikVSzgo6k2VghsWhgU";
r_addr = "ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk";

function ak2hex(ak_addr) {
    return "#" + bs58.decode(ak_addr.slice(3, i_addr.length))
                        .slice(0, 32).toString("hex");
}

i_addr_hex = i_addr;  // ak2hex(i_addr);
r_addr_hex = r_addr;  // ak2hex(r_addr);

const aeParams = {
    networkId: NETWORK_ID,
    url: API_URL,
    internalUrl: INTERNAL_API_URL,
    keypair: playerPair,
    compilerUrl: compilerURL
};

return Ae.Universal(aeParams);
}

You’ll notice it requires more information in comparison to what a simple node connection, that is because Universal class groups together multiple functionalities, including both of a node connection and the functionality of an account.

Note that for our example, the parameters indicate URLs of local test node and compilers, and addresses created -and funded- in the ForgAE docker image startup.

(2) In order to successfully create a State Channel, the initiator must first put a certain amount of AE to allow its creation:

player.spend(amount, "responder-public-address")

(3) With the connection open, you can create the State Channel to your peer. To do this, you must have its public address (see the open_channel function in the game code):

channel = await Channel({
    url: WS_URL,
    pushAmount: 3,
    initiatorAmount: 1000000000000000,
    responderAmount: 1000000000000000,
    channelReserve: 20000000000,
    ttl: 10000,
    host: 'localhost',
    port: 3001,
    lockPeriod: 1,
    initiatorId: i_addr,
    responderId: r_addr,
    role: role,
    sign: signer_func
});

Here signer_func is a function the channel client will use to sign and accept channel updates received from the other endpoint. Here follows a basic definition:

SignerFunc = async (tag, tx) => {
    console.log(" *  TAG:", tag);
    return player.signTransaction(tx)
};

(4) A tag is used to discriminate different messages or state transition types.

Additionally, in the open_channel function we setup an event handler for the State-Channel to be informed about its status:

channel.on("statusChanged", (status) => {
    channel_state = status.toUpperCase();
    console.log(`Channel statusChanged: [${channel_state}]`);
    console.log();

    if (channel_state == "DISCONNECTED")
        gamestate = GAMESTATE_DISCONNECTED;
});

Deploying and Calling a Contract Inside the State Channel

The contract is, in our case, deployed by the channel initiator, which is incidentally the WHITE player. After that this player will get a deployment address and it will be shared with the other player through a regular State-channel message.

If we have already loaded the contract’s source code into reversi_aes variable we use this function to:

  • Compile contract’s code
  • Encode the initial call to contract’s constructor function init
  • Deploy the contract in the channel we created (for this, we use again the sign-function)
  • Send the obtained address through channel to the opposite endpoint
async function compile_smartcontract() {
    console.log("Compiling smart contract..");
    return player.compileContractAPI(reversi_aes);
}

async function deploy_smartcontract(code) {
    var callData = await player.contractEncodeCallDataAPI(
                                    reversi_aes, "init",
                                    [i_addr_hex, r_addr_hex]);
    console.log("deploy_smartcontract call_data: ", callData);
    console.log("deploy_smartcontract code: ", code);
    var ctdata = await channel.createContract({
        code,
        callData,
        deposit: 1000,
        vmVersion: 3,
        abiVersion: 1
    }, async (tx) => await signer_func("create_contract", tx));

    return ctdata;
}

On the other end, we’ll need the deployed contract address in order to access it, no matter if it is already in the state channel. For that we setup another channel event handler:

channel.on("message", (msg) => {
        if (msg.info.split(" ")[0] === "contract_address") {
            contract_address = msg.info.split(" ")[1];
        }

Calling Contract Functions

We can perform two different kinds of calls to contract functions: stateless and stateful.

Stateless Function Calls

The simplest case we have is a contract function get_turn which receives no-arguments and returns an integer representing whose player the current turn is:

async function get_turn() {
    let call_data = await player.contractEncodeCallDataAPI(
                                    reversi_aes, "get_turn", []);
    const result = await channel.callContractStatic({
        amount: 0,
        callData: call_data,
        contract: contract_address,
        abiVersion: 1
    });
    let value = await player.contractDecodeCallResultAPI(
                                reversi_aes, "get_turn",
                                result.returnValue, result.returnType);
    return value;
}

As we did before with the constructor, we need to encode the call (function name and arguments), using the contract’s source code.
After that, we tell the channel to perform a static contract call. The result will be returned and must be decoded with the right type.

Stateful Function Calls

A stateful call is pretty similar, except that it will result in a transaction which will be sent in the channel.

function decode_tx(tx) {
    try {
        return Crypto.deserialize(Crypto.decodeTx(tx),
                                    {prettyTags: true});
    } catch (err) {
        console.log(err);
        console.log("TX:", tx);
    }
}

async function place_disc(my_addr, row, col) {
    const callData = await player.contractEncodeCallDataAPI(reversi_aes,
                    "place_disc", [my_addr, String(row), String(col)]);

    const result = await channel.callContract({
        amount: 0,
        callData,
        contract: contract_address,
        abiVersion: 1
    }, async (tx) => await signer_func(PLACE_DISC_TAG, tx)
    );

    if (result === undefined) {
        console.log("player_place_disc() contract call has returned "+
                    "undefined result!");
        gamestate = GAMESTATE_HALT_ERROR;
        return false;
    }

    const tx_decoded = decode_tx(result.signedTx);
    if (tx_decoded === undefined) {
        console.log("decoded TX is undefined");
        return false;
    }

    const decoded_result = await channel.getContractCall({
        caller: await player.address(),
        contract: contract_address,
        round: parseInt(tx_decoded.tx.round)
    });

    console.log(decoded_result);

    let value = await player.contractDecodeCallResultAPI(reversi_aes,
                                    "place_disc", decoded_result.returnValue,
                                    decoded_result.returnType);

    if (decoded_result.returnType === "error") {
        throw(decoded_result.value);
    }
    return value;
}

The only difference to the static-call being the call-function used which will return information containing the round in the channel where the call is made. That must be used later to retrieve the call result, by calling the function channel.getContractCall().

This contract function place_disc(addr: address,row: int, col: int ): int receives one address and two integer arguments that we must pass as a string. The int return value is returned as an integer with values specified in the contract source code.

Game State and Events

At the moment, Æternity SDK has no contract-event implementations. There are some alternatives for this:

  1. Although there are not exactly the same than contract generated events, channels do support custom messages between peers that you can use to let the other end know when change was produced in your side because of your action.
  2. Anytime the contract state is altered as a consecuence of a user interaction, a new state is generated and to be persisted it must first be received by the other user (the one who didn’t generated the change/transaction/call); who must sign it to be accepted and persisted in the channel. This is the approach we chose to keep the contract state synchronized in our application. We achieve this by using a custom signer function which will use custom tags to trigger contract changed events we will use later:
SignerFunc = async (tag, tx) => {
    if (tag === "shutdown_sign_ack") {
        console.log("TX:", tx);
    }
    if (tag === "update_ack") {
        if (gamestate == GAMESTATE_RESPONDER_WAITING_ADDRESS) {
            ResponderRecvdContract = true;
        }
        if (gamestate == GAMESTATE_WAIT_OTHER_PLAYER) {
            gamestate = GAMESTATE_MOVE_FINISHED;
        }
    }
    if (tag === PLACE_DISC_TAG) {
        rivalWithoutMoves = false;
    }
    if (tag === SWITCH_TURN_TAG) {
        // other player reported that she has no moves avail.

        rivalWithoutMoves = true;
    }
    return player.signTransaction(tx);
};

Closing Channel

A final call is required on the channel in order to close it, and make its final state get stored in the blockchain. That is done with this code:

async function end() {
    if (is_initiator) {
        channel.shutdown(tx => SignerFunc('tag_SHUTDOWN', tx))
            .then((tx) => {
                console.log('==> State channel has been closed. '+
                            'You can track this transaction on chain', tx)
            }).catch(e => {
                console.log('==> Error:', e);
        });
    }
}

Smart Contract development

Sophia: An Overview

Ethereum is known for introducing the concept of the “world computer”: code deployed on the blockchain, running on a virtual machine across all nodes in form of “smart contracts”, using the concept of “gas” as a medium to pay for computation. Æternity inherits those familiar concepts.
Solidity is the standard imperative language to write smart contracts for the Ethereum platform and the EVM. Æternity offers an alternative approach: program smart contracts using a functional language.

Based on ReasonML, support a typical range of functional features. The rationale for using a functional language is to make automated formal verification proofs easier. This is a critical point in Smart Contract development after many security issues that caused heavy fund losses in the past.

Sophia is a functional language in the ML family. We’ve chosen the functional programming paradigm because it makes it easier to write correct programs — something which is particularly important with smart contracts. The qualities of functional languages which make them (potentially) more reliable than programs written using the imperative paradigm include restricted mutable state, fewer side-effect, easier to read code components, better handling of concurrency, and ease of debugging and testing.

For details, see:

Contract and Game Flow

The Reversi game structure and rules that we implemented are: two players, 8×8 board. Rows and columns are numbered 0 to 7. Each board cell can have the following values: 0 for empty cell, 1 for a cell with a WHITE disc, and 2 for a cell with a BLACK disc.

The initial board is as follows:

The Reversi contract provides the following functions:

  • get_board to get the current board state
  • place_disc to place a players’ disc in the board
  • get_turn to get the current turn (black or white)
  • is_finished to get if the game is finished

(1) The typical sequence of calls in a game are:Contract is deployed in the state channel, initialized with the addresses of white and black players respectively. Initialization also sets up the initial board state and the initial turn to 1 (WHITE moves first).

(2) The game checks for the current turn calling get_turn contract function. Depending on the turn asks the player to wait or play.

(3) The place_disc function is called when the player wants to place a disc in a cell. This is the most complex function in the smart contract.

(3.1) It will check for necessary preconditions such as if the provided coordinate is an empty cell, and scan the cell to check if it will result in a valid move -that is, a move that overthrows the player rival’ pieces on the board.

(3.2) Remember that in Reversi, for each direction from your cell as center, you will overthrow any adjacent rival discs lying on a line, finishing with another piece of your own. For example:

(3.3) If the WHITE player calls the place_disc function with (row 7, col 3) as coordinate, the eight directions from that center will be scanned. Going in the up direction (same column, rows decreasing), we found two BLACK pieces in-line, finished with a WHITE (own) piece in row 3, col 3. This will cause the contract code to alter the board overthrowing pieces BLACK at R4, C3 and R5,C3 and replacing them with WHITE ones. The same will occur in the up-right diagonal direction, where R5, C4 will change to WHITE. The updated board state will be:

(3.4) Scanning is done in recursive form in the internal function scan_discs_to_reverse, that returns a list of cells that the current player will change playing. If this list is zero length, the place_disc function will return an integer code 4 meaning that’s not an available move; otherwise, the reverse_cells and flip_cell functions are called to flip the required discs.

(3.5) Finally, place_disc will switch the turn and return a successful error code (zero).

At this point, the game Javascript client code will update the view and pass the turn to the other player.

(4) At this point, the game Javascript client code will update the view and pass the turn to the other player.

Keep in mind that before each player is allowed to make a move, we check if they do have any movement available from the current board state. If no moves are available to the current player, his turn is passed-on to the rival. If also the rival is out-of-moves, the game is considered finished, in either a draw or a win to the player with the highest count of discs in the board.
Just as a sidenote, we really implemented this in the Sophia smart contract in our first versions of the game, but it was so expensive in terms of gas that it was impossible to do it.

We learned that contract calls in channels are limited to 1 millon of gas units. This puts a definitely quite important limit to the complexity of a single call. At the time of this writing, there is no option to modify gas provision in state channels.

Contract Debugging Tricks

Debugging smart contracts has been always quite complicated due to the immaturity of tools, and the implied transactional model. Even when working on a testnet does not require to waste real funds, development and test cycle is naturally slow.

Common practices in other environments such as placing breakpoints and inspecting state is either unavailable or very limited in the blockchain platform. Ethereum has been the leader so far with the Ganache RPC contract debugger.

For the development of the Reversi smart contract, we devised a simple but effective manner of reporting internal state in form of a “log” of debugging strings.

The contract maintains the following member in the game state:

debug_log: list(string)

which is initialized as an empty list:

debug_log = []

The aim of this state variable is to keep a debug log which will be returned in abort() calls. A new string can be appended to the log with the function:

private stateful function write_debug(s: string) =
  put(state{debug_log = s :: state.debug_log})

For example, a typical call could be:

write_debug(String.concat("Entry to function. param1=", Int.to_str(var1)))

We modified the require function to abort returning the stored logs:

private function require(condition: bool, err_string: string) =
  if (!condition)
   abort(String.concat(err_string, String.concat("DEBUG LOG: ", list_to_string(state.debug_log))))

The conversion from the debug_log of list(string) to a single string is done by the simple list_to_string helper function:

private function list_to_string(li:list (string)) : string =
 switch(li)
   [] => ""
   hd::tl => String.concat(hd, list_to_string(tl))

When abort is called triggering an exception, you can decode the error at the calling point with the AE Javascript SDK and checkout the logging information.

Note that storing information is expensive. You will be limited to gas restrictions even in testnet, so use this sparingly.

Function Call Gas Measurement

A contract function call gas cost can be known upfront, if a static call is executed; remind no state updates will occur.

A static call will return an object, if successful, with a field gasUsed. The following is a sample result data from get_turn static call:

{
  "callerId": "ak_fUq2NesPXcYZ1CcqBcGC3StpdnQw3iVxMA3YSeCNAwfN4myQk",
  "callerNonce": 7,
  "contractId": "ct_EZLZuzgEoPFA81HUiaXvX5kJyYPPfH4TjyXozWKz53rUhwLpi",
  "gasPrice": 1,
  "gasUsed": 655,
  "height": 7,
  "log": [],
  "returnType": "ok",
  "returnValue": "cb_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKTmJT3"
}

Just for comparison, get_board will consume about 1600 units of gas, while place_disc from ~22000 units upwards, depending on the required board scan recursion depth to know the outcome of the players’ move.

Unit Testing

The ForgAE toolset includes unit testing capabilities.

You can check the following links for further information:

– https://dev.aepps.com/tutorials/get-started-with-unit-testing.html

– https://dev.aepps.com/tutorials/how-to-write-unit-test-1.html

In case of our game, the test folder provides a contract testing Javascript file which simulates a game between two players, checking that the game setup, contract deployment and subsequent board state transitions are correct.

To run the tests, ensure you have the Æternity SDK installed. This is the standalone version, not the Webpack bundled for the browser. Ensure by running

npm install

on the root directory.

Once you get aepp-sdk@3.4.0 installed, you can run the test suite by executing:

forgae test

This will deploy the Reversi contract against the docker image nodes and start 18 tests including contract deployment, and several exchanges (game moves) between the two parties. Note that this is a contract functionality test, it does not use the State channel infrastructure.

You should see an output similar to:

For each piece call, the correct turn and board state are checked according to the game rules.

The test suite included does not reach the end-of-game board state but you can play by adding subsequent tests if you want, or by adding different kind of them.