Write Decentralized Unity Games using Reach
Hamza
Posted on September 21, 2021
It's impossible today to navigate the crypto space without hearing about decentralized apps, or “DApps”. They are similar to regular apps but live on a “blockchain”, which is a decentralized peer-to-peer network. Use cases range from non-fungible tokens (or “NFTs”) to data storage to the one we’ll discuss today: gaming.
In this tutorial, we’ll walk through the process of building a simple Unity game on the blockchain using Reach — a development platform which lets programmers build decentralized apps as they would a traditional application.
Requirements:
To follow this article, you should have:
- basic knowledge of JavaScript syntax,
- familiarity with sending HTTP requests, and
- a beginner’s understanding of game development in Unity.
You can final project here.
I’ll be available to answer questions and provide support in the Reach Discord server. I can’t wait to see what you build!
Why decentralize my game?
- Since the games do not pass through a single server, there is less downtime due to no single point of failure
- One update can update all versions of your game, minimizing issues with legacy conflicts
- There is no single vulnerable point on the chain, so the games are more secure
- Creators own the assets they produce and can choose to sell them off
What is Reach?
Reach is a programming language that is used to write applications to work on blockchains. It uses the JavaScript syntax and lets you define the program by specifying the participants and their roles inside the program.
In Reach, you just define the actors and their roles; the compiler handles the rest.
To use Reach, run these commands on your terminal. The first one installs the ‘reach’ script and the second one will update it to the latest version.
curl https://docs.reach.sh/reach -o reach ; chmod +x reach
sudo ./reach update
Quick Note: To run the executable you need to be on Linux or WSL if you’re using Windows. You also need to install “Docker”, “Docker Compose” and “make” .
To install Docker follow steps shown here
And to install Reach to Windows check out Chris’s explanation
After installing Reach, you should see it in your folder.
This file is called reach script and has all the tools needed to compile and run your Reach files.
The file extension for Reach smart contracts is .rsh
. Smart contract is the general name of the code running on a blockchain. You can either create one yourself or run.
./reach init
This creates an example Reach contract and a test file (index.rsh
and index.mjs
).
Open index.rsh in your favorite code editor. Reach has IDE support for Atom, VSCode and Sublime Text.
If you’re using WSL, you might have a hard time finding index.rsh file in your file explorer, as well as creating one. WSL uses a different file system and it is not exposed to the Windows by default, as it is partitioned on a separate drive. To locate the files, you can either open files in Visual Studio Code using...
code index.rsh
or open up the explorer in WSL to edit the files using...
explorer.exe .
For both commands, you need to make sure you’re in the right directory. For starters here’s how you navigate:
ls (Linux/Mac) / dir (Win)
- Show files and folders in the directory
cd <folder name>
- Navigate to the directory
cd ..
- Navigate to the parent folder
Writing Your Reach Script
./reach init
should give use a file like this
Check out lines 4–6. This is how you define participants. The first argument is the name of the participant and the second argument is an object containing all fields of the participant: integers, booleans, arrays, and functions. A full list of supported types is available here.
Let’s add some fields to the participants!
In blockchain programs, it's very common to represent monetary values with unsigned integers (represented by UInt
).
Accepting the offer and seeing the final state are functions. Functions are represented with the Fun
keyword. The first argument is the type the function accepts and the second argument is its return type.
After declaring all participants, use deploy() to start writing the actual program.
It is also a good time to talk about how a Reach program is structured.
As you remember, a Reach program consists of actors, actors’ actions, and calculations between those actions.
In Reach we refer those actors as participants. And whenever a participant is to do an action, we say that the program is in local step. It is called “local” because the code inside the local step runs on participants’ machines.
Whenever there is a calculation or a transfer outside of participant's local space, we say that the program is in consensus step because it is essentially running on a blockchain and blockchain is all about a consensus on what happens on it. Once something is recorded on a blockchain it is basically impossible to revert it.
We use only()
to transition into the local step and publish()
to transition into the consensus step. You can store information on the blockchain by passing variables inside the publish. You can also use pay()
to pay money to the contract.
As opposed to traditional databases, blockchains can also store money and distribute it under certain conditions. It is something that makes blockchains this powerful.
Here’s a visual of the transitions on blocks. Alice.only()
transitions to the Alice’s local block and Alice.publish()
transitions to the consensus block.
To sum this up, most of the time there’ll be an only()
block, publish()
statement and consensus transfers & calculations. After all consensus calculations are done, use commit()
to use only()
again.
Going to the local block is optional. The program will still work if you use payment or publish statements and commit statements after every publish/payment.
Let’s put all of this information into practice with a simple program.
In this program, Alice will share an offer with Bob to pay her; Bob will pay it if he accepts the offer.
After the deploy line, add this:
A.only(() => {
const offer = declassify(interact.offer);
});
A.only()
takes an arrow function that you can write code that will only work on A’s local block.
Why have
declassify
andinteract
all of a sudden?interact
refers to the fields of A we just declared. A had two fields: offer and seeResult. You can access them withinteract.offer
andinteract.seeResult
, respectively.
And thedeclassify
statement basically lets us publish the value.
To let B know the offer, we have to publish it.
A.publish(offer);
We don’t have any transfers or calculations, so we can just commit.
commit();
Now the code should look like this:
Now Bob has to accept or reject the offer in his only block.
B.only(() => {
const accepted = declassify(interact.acceptOffer(offer));
});
B.publish(accepted);
commit();
As we mentioned earlier, you can send funds to the contract. You do that by using the pay()
function.
pay()
can also be used to transition from local to consensus step.
B.pay(offer).when(accepted).timeout(absoluteSecs(2), () => {
Anybody.publish();
commit();
});
We also chain the payment with when
and timeout
statements to make sure Bob only pays when he accepts the offer. In the timeout statement, we simply say, “If I don’t pay in two seconds, let anybody (i.e. the first available participant) make the publication in Bob’s stead”. Now we commit after publishing.
We have the payment done, all we have to do next is to send it to Alice.
transfer(balance()).to(A);
To send funds to participants, use transfer().to()
where the argument to the transfer
function is the amount and argument to the to
function is either the address of the participant or directly the participant itself.
balance()
represents the total balance accumulated on the contract. If Bob accepted the offer, we should have offer
as the balance, otherwise we’d have 0.
With all the additions the contract should look like this:
'reach 0.1';
export const main = Reach.App(() => {
const A = Participant('Alice', {
offer: UInt,
seeSuccess: Fun([], Null)
});
const B = Participant('Bob', {
acceptOffer: Fun([UInt], Bool),
seeSuccess: Fun([], Null)
});
deploy();
A.only(() => {
const offer = declassify(interact.offer);
});
A.publish(offer);
commit();
B.only(() => {
const accepted = declassify(interact.acceptOffer(offer));
});
B.publish(accepted);
commit();
B.pay(offer).when(accepted).timeout(absoluteSecs(2), () => {
Anybody.publish();
commit();
});
transfer(balance()).to(A);
commit();
});
Under 40 lines we wrote a smart contract that handles a conditional payment.
Let’s use what we learned to build a contract for an actual game.
To keep this article brief, let’s again stick with a simple program: a racing game.
When designing your programs with Reach, the first thing you have to do is to find the participants in your program. You can have Participants for single participants and ParticipantClasses for multiple participants with the same set of functions (you can read more about this here).
While ParticipantClasses are very useful in DeFi and NFT apps, we’ll stick to Participants for this article.
After you define your participants, you should specify what fields your participants will have. Think about what that participant is supposed to do and you’re going to have a rough idea about what to include in a participant interface (this is what we call the fields of a participant).
Finally, decide the order of the interactions between participants and the consensus. In this part, write down when the local blocks will be called and when the transfers will be made.
This is the program we’ll use:
We have Alice and Bob as the racers (it’s common to use Alice and Bob as participant names, a convention from cryptography).
Alice can propose a wager for the race, Bob can accept that wager.
Both have start and finish functions. Both sides also will have a log function that’ll display the winner.
The steps to executing this contract are as follows:
First, Alice proposes a wager and pays it to the contract.
Then, Bob accepts it. If he doesn’t accept for a while, Alice’s funds are refunded. Otherwise, Bob also pays the wager.
Both start the race and try to call finish.
The first one to call finish function gets the price.
Both sides will see the winner.
Let’s start with defining participant interfaces:
Fun([], Null)
is the way we denote a function that takes no arguments and returns nothing; perfect for logging and notifying functions.
Since Alice and Bob have some common fields, we can add those fields into a separate object and use spread operator to include in both participants.
Now, we’ll take each step and write the local block - publish - commit combination for them.
- First, Alice proposes a wager and pays it to the contract.
Notice that you can chain a .pay() function while publishing. This will save us from an extra commit.
- Then, Bob accepts it. If he doesn’t accept for a while, Alice’s funds are returned to her. Otherwise, Bob also pays the wager.
Same process as we had before, let Alice publish instead of Bob, transfer funds to Alice, and commit after we’re done.
We also call exit()
since Bob timing out means an end to the program.
- Both start the race and try to call finish. To achieve this, we could simply write:
A.only(() => {
interact.start();
interact.finish()
});
B.only(() => {
interact.start();
interact.finish()
});
As you can see, it’d be repetitive for more participants. Reach provides a shorthand for these kinds of operations called each()
.
We assume that the start function will start the game for each player and finish will be called only when the game is finished. We’ll talk about it when we implement the participant interfaces.
- The first one to call finish functions gets the prize.
To get the user who “calls finish function”, we can simply observe the first person to publish after the local block. Assuming finish()
will halt until the game is complete, the player publishing first should be the winner.
Until now, we’ve seen participants publishing sequantially. Bob can’t publish where we write Alice.publish()
and vice versa. However, sometimes we want to have a single publish statement which any participant can complete.
This is where the race()
function in Reach is very useful.
We pass the participants that can do a certain thing (specifically a consensus transfer, a fancy word for publish/pay) and the function returns the first one to do that.
When you use race, this
keyword right after the statement points to the winner of the race. We pass the winning participant to the transfer function.
And in another block, we call seeResult
to show Alice and Bob the result. We do that by checking if “this” is “A”.
And we don’t have anything further we commit.
This is the final version of the contract.
'reach 0.1';
export const main = Reach.App(() => {
const CommonInterface = {
start: Fun([], Null),
finish: Fun([], Null),
seeResult: Fun([Bool], Null)
};
const A = Participant('Alice', {
...CommonInterface,
wager: UInt,
});
const B = Participant('Bob', {
...CommonInterface,
acceptWager: Fun([UInt], Null),
});
deploy();
A.only(() => {
const wager = declassify(interact.wager);
});
A.publish(wager).pay(wager);
commit();
B.only(() => {
interact.acceptWager(wager);
});
B.publish().pay(wager).timeout(relativeSecs(60), () => {
A.publish();
transfer(balance()).to(A);
commit();
exit();
});
commit();
each([A, B], () => {
interact.start();
interact.finish();
})
race(A, B).publish();
transfer(balance()).to(this);
each([A, B], () => {
interact.seeResult(this == A);
});
commit();
});
That settles all the steps. Once we are done with the program, compile it so that we can have a code to run on the blockchain.
sudo ./reach compile
Verifying knowledge assertions
Verifying for generic connector
Verifying when ALL participants are honest
Verifying when NO participants are honest
Verifying when ONLY "Alice" is honest
Verifying when ONLY "Bob" is honest
Checked 38 theorems; No failures!
Compiling might require admin privileges because it under the hood uses Docker and if you haven’t configured your docker, it requires
sudo
to run stuff.
Follow these steps to configure it.
The reach compile command defaults to index.rsh, but if you have a different name for your contract file, you can provide the contract name
sudo ./reach compile myContract.rsh
Verifying knowledge assertions
Verifying for generic connector
Verifying when ALL participants are honest
Verifying when NO participants are honest
Verifying when ONLY "Alice" is honest
Verifying when ONLY "Bob" is honest
Checked 38 theorems; No failures!
Compiling a contract generates an artifact in the build/ folder inside the contract’s directory.
We have completed the blockchain code and we can use this code to deploy to Ethereum, Algorand, Conflux, and any future additions to Reach’s growing list of protocol integrations. You don’t have to worry about this part now as we are going to use Reach’s development network to play around.
Building Your Unity Game
Let’s write the game part. Open up Unity and start a new project. To speed things up, we will use Unity’s 2D Platform Microgame template.
You can find it under Learn > 2D Platformer Microgame. Of course, you should give it a cooler name (because, if it’s not cool, what’s the point?)
One thing we’ll do is to add a ReachRPC script to our project. This plugin will help us connect to our Reach program.
ReachRPC currently relies on some C# 4.0> features such as dynamic types to work properly. In order to make it work head over Edit>Project Settings>Player>Other Settings>API Compatibility Level and select
.NET 4.x
.
I’ll add everything we will write in this article in a folder named Reach under the Scripts folder.
To use Reach RPC, you can create an instance of it using...
ReachRPC rpc =
new ReachRPC(
new RPCOptions(
"0.0.0.0",
"3000",
"opensesame"
)
);
RPCOptions is a class for configuring the RPC server. The first argument is the host of the server. The second argument is the port of the server and third one is the secret API key. This is a key you should keep secret while creating the RPC server. We’ll discuss this in more detail later on.
This is probably a good time to talk about what an RPC is. RPC is short for “remote procedure call” and (spoiler alert) it’s used to call remote procedures. In this case, these procedures are functions we use to interact with the blockchain. We interact with an RPC server by sending a POST request to the server. An additional API key is used in order to authenticate the user. The server then evaluates the requests and returns the output as a response. This enables us to interact with it using any language we want.
You may be familiar with setting up servers for your games, but Reach programs work peer-to-peer, so the connection is established between users of the application. This works by one user starting the app and other users joining it. We call these processes “deployment” and “attachment”.
There might be thousands of users deploying their own applications; so how do we know which application to connect to? When a user deploys an application, you can get the information of the contract and send it to the users you want to join your app.
To convert your game to a decentralized racing game, the first thing we have to add is UI for contract deployment and attachment. Alice needs to provide the amount she wants to wager. We get that information from the user while deploying the contract. This has to do more with Unity than Reach, so we’re going to fast forward through this part like 2010s Euro DIY shows. You can always use the project files if you don’t want to add them yourself.
Start screen of the result.The screen should look similar to this once an account is loaded
In this example, you can connect an account by either creating one or providing the mnemonic of an existing account.
A mnemonic is a set; this refers to words which can be used to calculate the private key of the account and it looks something like rough moment bunker outdoor rather ship smart album tiny thrive cancel choice elder flower device
Don’t share mnemonics or private keys with anyone. They can be used to access your money in your wallet.
The Create Game screen should look something like this
This is the screen where Alice shares contract information with Bob
The screen where Bob sees the wager
Screen that’ll be shown right after the game starts
This should be enough to have a minimal version of the app, but I encourage you to add your own personal touch.
The next step is binding UI fields to Reach functions. For that, navigate into Scripts/UI/MainUIController.cs
and update it as shown below.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace Platformer.UI
{
[System.Serializable]
public class ItemUI<T>
{
public string key;
public T item;
}
/// <summary>
/// A simple controller for switching between UI panels.
/// </summary>
public class MainUIController : MonoBehaviour
{
public GameObject[] panels;
public ItemUI<GameObject>[] subpanels;
public ItemUI<Text>[] texts;
public ItemUI<TextMeshProUGUI>[] tmps;
public ItemUI<InputField>[] inputFields;
public ItemUI<Button>[] buttons;
private Dictionary<string, GameObject> subpanelDict;
private Dictionary<string, Text> textDict;
private Dictionary<string, TextMeshProUGUI> tmpDict;
private Dictionary<string, InputField> inputDict;
private Dictionary<string, Button> buttonDict;
void Start()
{
subpanelDict = new Dictionary<string, GameObject>();
textDict = new Dictionary<string, Text>();
tmpDict = new Dictionary<string, TextMeshProUGUI>();
inputDict = new Dictionary<string, InputField>();
buttonDict = new Dictionary<string, Button>();
for (int i = 0; i < subpanels.Length; i++)
subpanelDict.Add(subpanels[i].key, subpanels[i].item);
for (int i = 0; i < texts.Length; i++)
textDict.Add(texts[i].key, texts[i].item);
for (int i = 0; i < tmps.Length; i++)
tmpDict.Add(tmps[i].key, tmps[i].item);
for (int i = 0; i < inputFields.Length; i++)
inputDict.Add(inputFields[i].key, inputFields[i].item);
for (int i = 0; i < buttons.Length; i++)
buttonDict.Add(buttons[i].key, buttons[i].item);
}
public void SetActivePanel(int index)
{
for (var i = 0; i < panels.Length; i++)
{
var active = i == index;
var g = panels[i];
if (g.activeSelf != active) g.SetActive(active);
}
}
public void SetActiveSubpanel(string key, bool active) =>
subpanelDict[key].SetActive(active);
public void SetText(string field, string text) =>
textDict[field].text = text;
public void SetButtonText(string button, string text) =>
buttonDict[button].transform.GetChild(0).GetComponent<Text>().text = text;
public void SetTmp(string field, string text) =>
tmpDict[field].text = text;
public string GetInputText(string field) =>
inputDict[field].text;
void OnEnable()
{
SetActivePanel(0);
}
}
}
There’s a lot of repetitive code here. Essentially, what it does is store relevant UI fields in different dictionaries. Once assigned, we can access them from other classes without doing the extra work of searching for their names or assigning them individually.
From now on, to have things organized, we’ll have one controller for Reach-related functions (ReachController
); one controller for UI-related functions (MainUIController
); and one for exposed functions to connect to the actual UI (ReachUIController
). Let’s start with the Reach controller and talk about how we interact with the RPC server.
RPCServer.cs
script exposes two functions for interacting with the RPC server. CallAsync
and Callbacks
.
CallAsync is used to call the RPC server endpoints, executing different functions depending on the endpoint. For example, making a POST
request to /acc/createAccount
with the current API key in the header of the request returns an account handle. And in the scripts it will look like..
string account = await CallAsync("/acc/createAccount");
When the call needs additional arguments, you can provide them with the path like this:
string startingBalance = await rpc.CallAsync("/stdlib/balanceOf", account);
CallAsync always returns a Task<string>
which you can wait for and get a string in return.
That explains the Call part, but what about the Async part? In programming, we don’t know when async functions will finish. Functions that interact with the network are often asynchronous and when we call them, the function is run in the background. We can also wait for async functions to finish by using the “await” (ONLY IN OTHER ASYNC FUNCTIONS) More on here.
In C#, the async return type is Task, where T is the type you’ll get after awaiting it.
Callbacks
is also an async function and is used to implement the participant interfaces. For example, in our game contract, Alice’s participant interface is “wager” integer,“start”, “finish”, and “seeResult” functions.
To implement them, we wrap the value types in an RPCValue and function types in an RPCCallback class.
The callback function takes 4 arguments:
- path (e.g “/backend/Alice”),
- contract (which refers to the handle you’ll get after deploying/attaching to a contract),
-
RPCValue
array of the participant, and -
RPCCallback
array of the participant.
Awaiting a callback function runs the code from the specified participant’s side and call functions related to the participant. Unless you explicitly halt the flow, the process will be automatic.
Sometimes (especially for getting values from the user while the Reach application is running) we await the user input. For this, we’ll use TaskCompletionSource, where T is the value we’re trying to get.
The steps for this are simple:
- We create a new TaskCompletionSource.
- We set a resolver (a function that completes a process) which will call the .SetResult function for our T.S.C.
- We await the .Task property of the T.S.C. It will return the actual value of the T.S.C. When the user finishes, they will call the resolver and we’ll get the result.
- We reset the resolver so that we can use it for other functions for which we want to have resolvers.
In the end, a function that uses this pattern will look like this:
Don’t forget to make this function asynchronous if it uses TaskCompletionSource.
There are two caveats for the current way a Callbacks function works:
- No matter what you return, you need to either have “dynamic” or “Task” as the return type of the callbacks in the callback array (depending on whether or not your function is “async”). If you don’t need to return anything, you need to return null;.
- The arguments the callback function takes are of type
RPCMultiTyped
. You can cast them to other types using.AsString()
,.AsBool()
,.AsInt()
, etc. Some casts like.AsFormattedCurrency(int precision)
need to be in anasync
function and used with await.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using Platformer.UI;
using Platformer.Mechanics;
public enum ContractRole { ALICE, BOB }
public class ReachConfig
{
public ContractRole role = ContractRole.ALICE;
public string account = "";
public string contract = "";
}
public class ReachController : MonoBehaviour
{
[SerializeField]
private string rpcHost = "0.0.0.0";
[SerializeField]
private int rpcPort = 3000;
[SerializeField]
private string rpcApiKey = "opensesame";
// Config
private ReachRPC rpc;
private Participant participant;
public ReachConfig config = new ReachConfig();
public async Task<string> GetAccountAndBalanceText()
{
try
{
string account = config.account;
string startingBalance = await rpc.CallAsync("/stdlib/balanceOf", account);
string formattedCurrency = (await rpc.CallAsync("/stdlib/formatCurrency", startingBalance, 4)).Replace("\"", "");
string formattedAccount = account.Replace("\"", "");
return "ACCOUNT: " +
formattedAccount.Substring(0, 4) +
"..." +
formattedAccount.Substring(formattedAccount.Length - 3, 3) +
" - BALANCE: " +
formattedCurrency +
" ALGO";
}
catch (Exception e)
{
throw new Exception("[-] GetAccountAndBalanceText " + e.Message);
}
}
public async Task CreateNewAccount()
{
try
{
string startingBalance = await rpc.CallAsync("/stdlib/parseCurrency", 10);
string account = await rpc.CallAsync("/stdlib/newTestAccount", startingBalance);
config.account = account;
}
catch (Exception e)
{
throw new Exception("[-] CreateNewAccount " + e.Message);
}
}
public async Task GetAccountFromMnemonic(string mnemonic)
{
try
{
config.account = await rpc.CallAsync("/stdlib/newAccountFromMnemonic", mnemonic);
}
catch (Exception e)
{
throw new Exception("[-] GetAccountFromMnemonic " + e.Message);
}
}
public async Task DeployContract()
{
try
{
string account = config.account;
config.role = ContractRole.ALICE;
config.contract = await rpc.CallAsync("/acc/deploy", account);
}
catch (Exception e)
{
throw new Exception("[-] DeployContract " + e.Message);
}
}
public async Task AttachContract(string contractInfo)
{
if (string.IsNullOrEmpty(contractInfo))
{
throw new ArgumentException($"'{nameof(contractInfo)}' cannot be null or empty.", nameof(contractInfo));
}
try
{
string account = config.account;
config.role = ContractRole.BOB;
config.contract = await rpc.CallAsync("/acc/attach", account, contractInfo);
}
catch (Exception e)
{
throw new Exception("[-] AttachContract " + e.Message);
}
}
public async Task RunGame(MainUIController uIController, PlayerController playerController)
{
if (config.role == ContractRole.ALICE)
participant = new Alice(
uIController,
config,
playerController,
rpc
);
else
participant = new Bob(
uIController,
config,
playerController,
rpc
);
string backendHandle = config.role == ContractRole.ALICE ? "Alice" : "Bob";
RPCValue[] values = config.role == ContractRole.ALICE
? await participant.GetValuesAsync()
: participant.GetValues();
RPCCallback[] callbacks = participant.GetCallbacks();
await rpc.Callbacks(
"/backend/" + backendHandle,
config.contract,
values,
callbacks
);
}
public void CallParticipantResolve(object arg)
{
participant.CallResolve(arg);
}
void Start()
{
rpc = new ReachRPC(
new RPCOptions(rpcHost, rpcPort, rpcApiKey));
}
void Update()
{
}
}
At the Start function, we assign the rpc
variable with a new ReachRPC instance.
void Start()
{
rpc = new ReachRPC(
new RPCOptions(
rpcHost,
rpcPort,
rpcApiKey
)
);
}
Let’s look at the CreateNewAccount function, for example. The most important part of the function are these three lines:
string startingBalance = await rpc.CallAsync("/stdlib/parseCurrency", 10);
string account = await rpc.CallAsync("/stdlib/newTestAccount", startingBalance);
config.account = account;
We first call parseCurrency
with argument ten to get the desired starting balance. On almost all blockchains, even though you’d spend ten tokens from the outside considering the decimal digits you’re allowed to have in your transaction (which change between nine and 18) what you’re actually spending is 10*10⁹ or 10*10¹⁸ tokens (depending on the protocol). So we “parse” the amount we want to spend to get the actual value.
Then, we call stdlib/newTestAccount
with the startingBalance
. newTestAccount
basically creates a new account with the balance amount inside it. Normally, you’d need real blockchain tokens (which cost money) to run the code, but during development, you can simulate that in test environments.
At last, we set the config’s account so that we can remember the account information when calling other functions inside the class. You could also store this information to access the account between sessions.
After creating accounts, we need to connect to a contract to play a game. For that, we have Deploy and Attach functions between the 85th and 117th lines.
string account = config.account;
config.role = ContractRole.ALICE;
config.contract = await rpc.CallAsync("/acc/deploy", account);
We get the account from the configuration and then set the role of the user in the game (currently Alice or Bob). After that, calling /acc/deploy
with the account is enough to get the contract deployed. Once that is done, we store the contract information (to share it later) and we’re done.
Attachment has the same process, but in that case, you also need the contractInfo
you get from the deployer to connect to the right contract.
Another important function in the file is the RunGame
function. It calls the Callbacks function and starts the game.
if (config.role == ContractRole.ALICE)
participant = new Alice(
uIController,
config,
playerController,
rpc
);
else
participant = new Bob(
uIController,
config,
playerController,
rpc
);
We set the contract role (the participant) in the game at lines 90 and 109. In this function, we check that role to instantiate either Alice or Bob. These two classes are from Participants.cs
. They implement the participant interfaces, specifying what happens when start
, finish
, and seeResult
functions are called, for example.
using System;
using System.Threading.Tasks;
using System.Threading;
using Platformer.Mechanics;
using Platformer.UI;
using UnityEngine;
public abstract class Participant
{
protected Action<object> resolver = null;
public void CallResolve(object arg)
{
resolver(arg);
}
public bool IsResolveReady()
{
return resolver != null;
}
protected MainUIController uiController;
protected ReachConfig config;
protected PlayerController player;
protected ReachRPC rpc;
public virtual RPCCallback[] GetCallbacks()
{
throw new NotImplementedException("GetCallbacks not implemented");
}
public virtual async Task<RPCCallback[]> GetCallbacksAsync()
{
// To get rid of the warning await 10 ms delay
await Task.Factory.StartNew(() =>
{
Thread.Sleep(10);
});
throw new NotImplementedException("GetCallbacksAsync not implemented");
}
public virtual RPCValue[] GetValues()
{
throw new NotImplementedException("GetValues not implemented");
}
public virtual async Task<RPCValue[]> GetValuesAsync()
{
// To get rid of the warning, await 10 ms delay
await Task.Factory.StartNew(() =>
{
Thread.Sleep(10);
});
throw new NotImplementedException("GetValuesAsync not implemented");
}
}
public class Alice : Participant
{
private string prize;
public Alice(
MainUIController _uiController,
ReachConfig _config,
PlayerController _player,
ReachRPC _rpc
) => (uiController, config, player, rpc) = (_uiController, _config, _player, _rpc);
public async override Task<RPCValue[]> GetValuesAsync()
{
float wager = float.Parse(uiController.GetInputText("Wager"));
prize = (wager * 2).ToString();
string parsedWager = await rpc.CallAsync("/stdlib/parseCurrency", wager);
return new RPCValue[] {
new RPCValue("wager", parsedWager)
};
}
public override RPCCallback[] GetCallbacks()
{
return new RPCCallback[] {
new RPCCallback("seeResult", seeResult),
new RPCCallback("start", start),
new RPCCallback("finish", finish)
};
}
private dynamic seeResult(RPCMultiTyped[] args)
{
bool didAliceWon = args[0].AsBool();
string resultText = didAliceWon
? "YOU WON"
: "YOU LOST";
string prizeText = didAliceWon
? "PRIZE: " + prize + " ALGO"
: "BETTER LUCK NEXT TIME";
uiController.SetTmp("Result", resultText);
uiController.SetTmp("Prize", prizeText);
uiController.SetActivePanel(2);
return null;
}
private dynamic start(RPCMultiTyped[] args)
{
uiController.SetActivePanel(1);
// Continue game
player.controlEnabled = true;
return null;
}
private async Task<dynamic> finish(RPCMultiTyped[] args)
{
var finishTask = new TaskCompletionSource<bool>();
resolver = (obj) =>
{
finishTask.SetResult(true);
};
await finishTask.Task;
resolver = null;
return null;
}
}
public class Bob : Participant
{
private string prize;
public Bob(
MainUIController _uiController,
ReachConfig _config,
PlayerController _player,
ReachRPC _rpc
) => (uiController, config, player, rpc) = (_uiController, _config, _player, _rpc);
public override RPCValue[] GetValues()
{
return new RPCValue[0];
}
public override RPCCallback[] GetCallbacks()
{
return new RPCCallback[] {
new RPCCallback("acceptWager", acceptWager),
new RPCCallback("start", start),
new RPCCallback("finish", finish),
new RPCCallback("seeResult", seeResult)
};
}
private async Task<dynamic> acceptWager(RPCMultiTyped[] args)
{
string wagerString = await args[0].AsFormattedCurrency(4);
prize = (float.Parse(wagerString) * 2).ToString();
uiController.SetActiveSubpanel("EnterCtcInfo", false);
uiController.SetText("AcceptWager",
"Wager: " + wagerString + " ALGO");
uiController.SetActiveSubpanel("AcceptWager", true);
var acceptTask = new TaskCompletionSource<bool>();
resolver = (object obj) =>
{
acceptTask.SetResult((bool)obj);
};
var result = await acceptTask.Task;
resolver = null;
if (!result)
Application.Quit(0);
return null;
}
private dynamic seeResult(RPCMultiTyped[] args)
{
bool didBobWon = !(args[0].AsBool());
string resultText = didBobWon
? "YOU WON"
: "YOU LOST";
string prizeText = didBobWon
? "PRIZE: " + prize + " ALGO"
: "BETTER LUCK NEXT TIME";
uiController.SetTmp("Result", resultText);
uiController.SetTmp("Prize", prizeText);
uiController.SetActivePanel(2);
return null;
}
private dynamic start(RPCMultiTyped[] args)
{
uiController.SetActivePanel(1);
// Continue game
player.controlEnabled = true;
return null;
}
private async Task<dynamic> finish(RPCMultiTyped[] args)
{
var finishTask = new TaskCompletionSource<bool>();
resolver = (obj) =>
{
finishTask.SetResult(true);
};
await finishTask.Task;
resolver = null;
return null;
}
}
You would have different classes for different participants in your app but Participant abstract class can be used as a reference for different projects.
We’ll use participant instances to get value & callback arrays.
In the next part of the RunGame
function, we have these lines which decide the back-end handle and value & callback arrays to use depending on the current contract role.
string backendHandle = config.role == ContractRole.ALICE
? "Alice"
: "Bob";
RPCValue[] values = config.role == ContractRole.ALICE
? await participant.GetValuesAsync()
: participant.GetValues();
RPCCallback[] callbacks = participant.GetCallbacks();
? and : used together is called ternary operators. After condition ? is selected when condition is true and : is selected when condition is false. Some love it, some hate it but at the end, it shortens the conditional assignments.
In case we need an async operation while getting values array, you can use GetValuesAsync
. Otherwise, we use the GetValues
function to get the values array.
It is also possible to have this for callbacks. But both Alice and Bob use the synchronous version of the function, so there’s no need to check if we’re Alice or Bob.
At last, we call:
await rpc.Callbacks(
"/backend/" + backendHandle,
config.contract,
values,
callbacks
);
to actually run the game. That’s it.
This concludes everything you need to know in order to implement Reach in your game. The rest is to implement the game logic inside the participant interface, bind UI buttons to the right functions, etc. ReachUIController
bundles the functions from MainUIController
and ReachController
to expose functions to use for the buttons in our start screen.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Platformer.UI;
using Platformer.Mechanics;
public class ReachUIController : MonoBehaviour
{
[SerializeField]
private ReachController reachController;
[SerializeField]
private MainUIController mainUIController;
private PlayerController playerController;
private async void HandleCreateAccount()
{
await reachController.CreateNewAccount();
mainUIController.SetText("Account",
await reachController.GetAccountAndBalanceText());
mainUIController.SetActiveSubpanel("NotConnected", false);
mainUIController.SetActiveSubpanel("Connected", true);
}
private async void HandleConnectAccount()
{
string mnemonic = mainUIController.GetInputText("Mnemonic");
await reachController.GetAccountFromMnemonic(mnemonic);
mainUIController.SetText("Account",
await reachController.GetAccountAndBalanceText());
mainUIController.SetActiveSubpanel("NotConnected", false);
mainUIController.SetActiveSubpanel("Connected", true);
}
private async void HandleCreateGame()
{
await reachController.DeployContract();
// Set UI for sharing contract
mainUIController.SetText("CtcInfo",
"SHARE THIS WITH YOUR OPPONENT:\n" +
reachController.config.contract
);
mainUIController.SetActiveSubpanel("WagerAmount", false);
mainUIController.SetActiveSubpanel("ShareContract", true);
await reachController.RunGame(
mainUIController,
playerController
);
}
private async void HandleJoinGame()
{
string contractInfo = mainUIController.GetInputText("CtcInfo");
await reachController.AttachContract(contractInfo);
await reachController.RunGame(
mainUIController,
playerController
);
}
/* BUTTON ASSIGNMENTS */
public void OnCreateAccountClicked()
{
HandleCreateAccount();
}
public void OnConnectAccountClicked()
{
HandleConnectAccount();
}
public void OnCreateGameTransitionClicked()
{
mainUIController.SetActiveSubpanel("NoSelection", false);
mainUIController.SetActiveSubpanel("CreateGame", true);
}
public void OnNoSelectionTransitionClicked()
{
mainUIController.SetActiveSubpanel("CreateGame", false);
mainUIController.SetActiveSubpanel("JoinGame", false);
mainUIController.SetActiveSubpanel("NoSelection", true);
}
public void OnCreateGameClicked()
{
HandleCreateGame();
}
public void OnJoinGameTransitionClicked()
{
mainUIController.SetActiveSubpanel("NoSelection", false);
mainUIController.SetActiveSubpanel("JoinGame", true);
}
public void OnCopyContractClicked()
{
reachController.config.contract.CopyToClipboard();
mainUIController.SetButtonText("Copy", "COPIED");
}
public void OnJoinGameClicked()
{
HandleJoinGame();
}
public void OnAcceptWagerClicked()
{
reachController.CallParticipantResolve(true);
}
public void OnRejectWagerClicked()
{
// For now it'll exit the application
Application.Quit();
}
void Start()
{
playerController = GameObject.Find("Player").GetComponent<PlayerController>();
}
}
public static class ClipboardExtension
{
/// <summary>
/// Puts the string into the Clipboard.
/// </summary>
public static void CopyToClipboard(this string str)
{
GUIUtility.systemCopyBuffer = str;
}
}
Next, we jump to the editor and assign the fields for our scripts.
UI Canvas
Reach Controller
For the last step of connecting scripts, we add the Reach Controller to the Game Controller object. The configuration in the photo is the default values for the RPC server. Soon, we’ll talk about how to change those values while starting the RPC server.
To start the RPC server, head over to the directory where your index.rsh and reach script is located (it doesn’t matter whether you’re using WSL or Linux) and run ./reach rpc-server
. Reach will compile your contract and start a new RPC server for you to communicate with the blockchain.
zet@zetpad ~> cd dev/unity/my-decentralized-game/
zet@zetpad ~/d/u/my-decentralized-game> ./reach rpc-server
Warning! Using development RPC key: REACH_RPC_KEY=opensesame.
Warning! The current TLS certificate is only suitable for development purposes.
Verifying knowledge assertions
Verifying for generic connector
Verifying when ALL participants are honest
Verifying when NO participants are honest
Verifying when ONLY "Alice" is honest
Verifying when ONLY "Bob" is honest
Checked 38 theorems; No failures!
Creating reach2021-09-14t17-50-27z-rvhy_reach-app-my-decentralized-game_run ... done
> @reach-sh/rpc-server@ app /app
> node --experimental-modules --unhandled-rejections=strict index.mjs
If you don’t get any error, it means RPC server is running on https://0.0.0.0:3000
.
To change the host, port, and API key, set
-
REACH_RPC_SERVER
env. variable for host, -
REACH_RPC_PORT
for port -
REACH_RPC_KEY
for the API key.
When you’re going to actually deploy, you also need to set REACH_RPC_TLS_REJECT_UNVERIFIED
to “1” so that the RPC server will only accept verified connections.
Environment variables are global configuration values you can set before using certain programs.
export REACH_RPC_SERVER=12.23.34.45
, for example, sets the RPC host to12.23.34.45
.
Once you get the RPC server running, build the game and run two instances of it. It should work right away. Here’s an example run:
Posted on September 21, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.