- {contract.address}
-
+ {contract.address}
+
{contractStorages.get(contract.address!) !== undefined &&
contractStorages.get(contract.address!)!.pokeTraces
? Array.from(
@@ -598,19 +598,19 @@ Update the unit tests files to see if you can still poke.
).map(
(e) =>
e[1].receiver +
- " " +
+ ' ' +
e[1].feedback +
- " " +
+ ' ' +
e[0] +
- ","
+ ','
)
- : ""}
+ : ''}
-
+
{
- console.log("e", e.currentTarget.value);
+ console.log('e', e.currentTarget.value);
setContractToPoke(e.currentTarget.value);
}}
placeholder="enter contract address here"
diff --git a/docs/tutorials/dapp/part-4.md b/docs/tutorials/dapp/part-4.md
index a4a672703..4c43a9db6 100644
--- a/docs/tutorials/dapp/part-4.md
+++ b/docs/tutorials/dapp/part-4.md
@@ -1,6 +1,6 @@
---
-title: "Part 4: Smart contract upgrades"
-authors: "Benjamin Fuentes"
+title: 'Part 4: Smart contract upgrades'
+authors: 'Benjamin Fuentes (Marigold)'
last_update:
date: 29 November 2023
---
@@ -1118,28 +1118,28 @@ const default_storage = {
1. Edit `./app/src/App.tsx` and change the contract address, display, etc ...
```typescript
- import { NetworkType } from "@airgap/beacon-types";
- import { BeaconWallet } from "@taquito/beacon-wallet";
- import { PackDataResponse } from "@taquito/rpc";
- import { MichelCodecPacker, TezosToolkit } from "@taquito/taquito";
- import * as api from "@tzkt/sdk-api";
- import { useEffect, useState } from "react";
- import "./App.css";
- import ConnectButton from "./ConnectWallet";
- import DisconnectButton from "./DisconnectWallet";
+ import { NetworkType } from '@airgap/beacon-types';
+ import { BeaconWallet } from '@taquito/beacon-wallet';
+ import { PackDataResponse } from '@taquito/rpc';
+ import { MichelCodecPacker, TezosToolkit } from '@taquito/taquito';
+ import * as api from '@tzkt/sdk-api';
+ import { useEffect, useState } from 'react';
+ import './App.css';
+ import ConnectButton from './ConnectWallet';
+ import DisconnectButton from './DisconnectWallet';
import {
Storage as ContractStorage,
PokeGameWalletType,
- } from "./pokeGame.types";
- import { Storage as ProxyStorage, ProxyWalletType } from "./proxy.types";
- import { address, bytes } from "./type-aliases";
+ } from './pokeGame.types';
+ import { Storage as ProxyStorage, ProxyWalletType } from './proxy.types';
+ import { address, bytes } from './type-aliases';
function App() {
- api.defaults.baseUrl = "https://api.ghostnet.tzkt.io";
+ api.defaults.baseUrl = 'https://api.ghostnet.tzkt.io';
- const Tezos = new TezosToolkit("https://ghostnet.tezos.marigold.dev");
+ const Tezos = new TezosToolkit('https://ghostnet.tezos.marigold.dev');
const wallet = new BeaconWallet({
- name: "Training",
+ name: 'Training',
preferredNetwork: NetworkType.GHOSTNET,
});
Tezos.setWalletProvider(wallet);
@@ -1156,7 +1156,7 @@ const default_storage = {
import.meta.env.VITE_CONTRACT_ADDRESS,
{
includeStorage: true,
- sort: { desc: "id" },
+ sort: { desc: 'id' },
}
);
setContracts(tzktcontracts);
@@ -1171,30 +1171,30 @@ const default_storage = {
const s: ProxyStorage = await c.storage();
try {
let firstEp: { addr: address; method: string } | undefined =
- await s.entrypoints.get("Poke");
+ await s.entrypoints.get('Poke');
if (firstEp) {
let underlyingContract: PokeGameWalletType =
- await Tezos.wallet.at("" + firstEp!.addr);
+ await Tezos.wallet.at('' + firstEp!.addr);
map.set(c.address, {
...s,
...(await underlyingContract.storage()),
});
} else {
console.log(
- "proxy is not well configured ... for contract " + c.address
+ 'proxy is not well configured ... for contract ' + c.address
);
continue;
}
} catch (error) {
console.log(error);
console.log(
- "final contract is not well configured ... for contract " +
+ 'final contract is not well configured ... for contract ' +
c.address
);
}
}
- console.log("map", map);
+ console.log('map', map);
setContractStorages(map);
})();
};
@@ -1210,34 +1210,34 @@ const default_storage = {
})();
}, []);
- const [userAddress, setUserAddress] = useState("");
+ const [userAddress, setUserAddress] = useState('');
const [userBalance, setUserBalance] = useState(0);
- const [contractToPoke, setContractToPoke] = useState("");
+ const [contractToPoke, setContractToPoke] = useState('');
//poke
const poke = async (
e: React.MouseEvent,
contract: api.Contract
) => {
e.preventDefault();
- let c: ProxyWalletType = await Tezos.wallet.at("" + contract.address);
+ let c: ProxyWalletType = await Tezos.wallet.at('' + contract.address);
try {
- console.log("contractToPoke", contractToPoke);
+ console.log('contractToPoke', contractToPoke);
const p = new MichelCodecPacker();
let contractToPokeBytes: PackDataResponse = await p.packData({
data: { string: contractToPoke },
- type: { prim: "address" },
+ type: { prim: 'address' },
});
- console.log("packed", contractToPokeBytes.packed);
+ console.log('packed', contractToPokeBytes.packed);
const op = await c.methods
.callContract(
- "PokeAndGetFeedback",
+ 'PokeAndGetFeedback',
contractToPokeBytes.packed as bytes
)
.send();
await op.confirmation();
- alert("Tx done");
+ alert('Tx done');
} catch (error: any) {
console.log(error);
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
@@ -1250,22 +1250,22 @@ const default_storage = {
contract: api.Contract
) => {
e.preventDefault();
- let c: ProxyWalletType = await Tezos.wallet.at("" + contract.address);
+ let c: ProxyWalletType = await Tezos.wallet.at('' + contract.address);
try {
- console.log("contractToPoke", contractToPoke);
+ console.log('contractToPoke', contractToPoke);
const p = new MichelCodecPacker();
let initBytes: PackDataResponse = await p.packData({
data: {
- prim: "Pair",
- args: [{ string: userAddress }, { int: "1" }],
+ prim: 'Pair',
+ args: [{ string: userAddress }, { int: '1' }],
},
- type: { prim: "Pair", args: [{ prim: "address" }, { prim: "nat" }] },
+ type: { prim: 'Pair', args: [{ prim: 'address' }, { prim: 'nat' }] },
});
const op = await c.methods
- .callContract("Init", initBytes.packed as bytes)
+ .callContract('Init', initBytes.packed as bytes)
.send();
await op.confirmation();
- alert("Tx done");
+ alert('Tx done');
} catch (error: any) {
console.log(error);
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
@@ -1306,10 +1306,10 @@ const default_storage = {
{contracts.map((contract) => (
-
+
{contract.address}
-
+
{contractStorages.get(contract.address!) !== undefined &&
contractStorages.get(contract.address!)!.pokeTraces
? Array.from(
@@ -1319,19 +1319,19 @@ const default_storage = {
).map(
(e) =>
e[1].receiver +
- " " +
+ ' ' +
e[1].feedback +
- " " +
+ ' ' +
e[0] +
- ","
+ ','
)
- : ""}
+ : ''}
-
+
{
- console.log("e", e.currentTarget.value);
+ console.log('e', e.currentTarget.value);
setContractToPoke(e.currentTarget.value);
}}
placeholder="enter contract address here"
diff --git a/docs/tutorials/mobile.md b/docs/tutorials/mobile.md
new file mode 100644
index 000000000..60ef34697
--- /dev/null
+++ b/docs/tutorials/mobile.md
@@ -0,0 +1,54 @@
+---
+title: Create a mobile game
+authors: 'Benjamin Fuentes (Marigold)'
+last_update:
+ date: 12 December 2023
+---
+
+![home](/img/tutorials/mobile-picHOME.png)
+
+Web3 mobile gaming is a new era of decentralized, blockchain-based gaming that promises to revolutionize the industry. It combines gaming with the unique features of blockchain technology, such as secure and transparent transactions, digital asset ownership, and decentralized governance. In Web3 gaming, players can enjoy a wide range of gaming experiences and participate in the creation, management, and monetization of these games through the use of cryptocurrencies, non-fungible tokens (NFTs), and decentralized autonomous organizations (DAOs).
+
+Web3 gaming is still in its early stages, but it has the potential to transform the gaming industry and create new opportunities for gamers and developers alike.
+There are two categories of web3 gaming dapp:
+
+- The ones including web3 parts like NFT or fungible tokens but represent generally less than 25% of the application
+- The ones which are 100% onchain, like on this tutorial, where all the logic is coded inside the smart contract
+
+You will learn:
+
+- How to import a Ligo smart contract library containing the game logic.
+- How to create a mobile app with Ionic.
+- How to integrate the taquito library to connect a wallet.
+- How to develop the UI and interact with your smart contract.
+- How to build and deploy your game to the Android store.
+
+## Prerequisites
+
+This tutorial uses TypeScript, so it will be easier if you are familiar with JavaScript.
+
+1. Make sure that you have installed these tools:
+
+ - [Node.JS and NPM](https://nodejs.org/en/download/): NPM is required to install the web application's dependencies. (currently using v18.15.0 on the solution)
+ - [Taqueria](https://taqueria.io/), version 0.46.0 or later: Taqueria is a platform that makes it easier to develop and test dApps.
+ - [Docker](https://docs.docker.com/engine/install/): Docker is required to run Taqueria.
+ - [jq](https://stedolan.github.io/jq/download/): Some commands use the `jq` program to extract JSON data.
+ - [`yarn`](https://yarnpkg.com/): The frontend application uses yarn to build and run (see this article for details about [differences between `npm` and `yarn`](https://www.geeksforgeeks.org/difference-between-npm-and-yarn/)).
+ - Any Tezos-compatible wallet that supports Ghostnet, such as [Temple wallet](https://templewallet.com/).
+
+2. Optionally, you can install [`VS Code`](https://code.visualstudio.com/download) to edit your application code in and the [LIGO VS Code extension](https://marketplace.visualstudio.com/items?itemName=ligolang-publish.ligo-vscode) for LIGO editing features such as code highlighting and completion.
+ Taqueria also provides a [Taqueria VS Code extension](https://marketplace.visualstudio.com/items?itemName=ecadlabs.taqueria-vscode) that helps visualize your project and run tasks.
+
+## The tutorial game
+
+Shifumi or Rock paper scissors (also known by other orderings of the three items, with "rock" sometimes being called "stone," or as Rochambeau, roshambo, or ro-sham-bo) is a hand game originating from China, usually played between two people, in which each player simultaneously forms one of three shapes with an outstretched hand.
+
+These shapes are "rock" (a closed fist), "paper" (a flat hand), and "scissors" (a fist with the index finger and middle finger extended, forming a V). "Scissors" is identical to the two-fingered V sign (also indicating "victory" or "peace") except that it is pointed horizontally instead of being held upright in the air.
+
+[Wikipedia link](https://en.wikipedia.org/wiki/Rock_paper_scissors)
+
+The application can be downloaded on [the Android store here](https://play.google.com/store/apps/details?id=dev.marigold.shifumi)
+
+The code for the completed application is in this GitHub repository: [solution](https://github.com/marigold-dev/training-dapp-shifumi/tree/main/solution)
+
+When you're ready, move to the next section [Part 1: Create the smart contract](./mobile/part-1) to begin setting up the application.
diff --git a/docs/tutorials/mobile/part-1.md b/docs/tutorials/mobile/part-1.md
new file mode 100644
index 000000000..64bced378
--- /dev/null
+++ b/docs/tutorials/mobile/part-1.md
@@ -0,0 +1,125 @@
+---
+title: 'Part 1: Create the smart contract'
+authors: 'Benjamin Fuentes (Marigold)'
+last_update:
+ date: 12 December 2023
+---
+
+On this first section, you will:
+
+- Create the game smart contract importing an existing Ligo library
+- Deploy your smart contract to the Ghostnet
+- Get the Shifumi Git repository folders to copy the game UI and CSS for the second party
+
+## Smart contract
+
+1. Clone the repository and start a new Taqueria project:
+
+ ```bash
+ git clone https://github.com/marigold-dev/training-dapp-shifumi.git
+ taq init shifumi
+ cd shifumi
+ taq install @taqueria/plugin-ligo
+ ```
+
+1. Download the Ligo Shifumi template, and copy the files to Taqueria **contracts** folder:
+
+ ```bash
+ TAQ_LIGO_IMAGE=ligolang/ligo:1.2.0 taq ligo --command "init contract --template shifumi-jsligo shifumiTemplate"
+ cp -r shifumiTemplate/src/* contracts/
+ ```
+
+1. Compile the contract. It creates the default required file `main.storageList.jsligo`:
+
+ ```bash
+ TAQ_LIGO_IMAGE=ligolang/ligo:1.2.0 taq compile main.jsligo
+ ```
+
+1. Edit `main.storageList.jsligo` initial storage and save it:
+
+ ```ligolang
+ #import "main.jsligo" "Contract"
+
+ const default_storage: Contract.storage = {
+ metadata: Big_map.literal(
+ list(
+ [
+ ["", bytes `tezos-storage:contents`],
+ [
+ "contents",
+ bytes
+ `
+ {
+ "name": "Shifumi Example",
+ "description": "An Example Shifumi Contract",
+ "version": "beta",
+ "license": {
+ "name": "MIT"
+ },
+ "authors": [
+ "smart-chain "
+ ],
+ "homepage": "https://github.com/ligolang/shifumi-jsligo",
+ "source": {
+ "tools": "jsligo",
+ "location": "https://github.com/ligolang/shifumi-jsligo/contracts"
+ },
+ "interfaces": [
+ "TZIP-016"
+ ]
+ }
+ `
+ ]
+ ]
+ )
+ ) as big_map,
+ next_session: 0 as nat,
+ sessions: Map.empty as map,
+ }
+ ```
+
+1. Compile again:
+
+ ```bash
+ TAQ_LIGO_IMAGE=ligolang/ligo:1.2.0 taq compile main.jsligo
+ ```
+
+1. Deploy to Ghostnet:
+
+ ```bash
+ taq install @taqueria/plugin-taquito
+ taq deploy main.tz -e "testing"
+ ```
+
+ > Note: If this is your first time using Taqueria, look at this training first: [https://github.com/marigold-dev/training-dapp-1#ghostnet-testnet](https://github.com/marigold-dev/training-dapp-1#ghostnet-testnet)
+ >
+ > For advanced users, just go to `.taq/config.local.testing.json` , replace account with alice settings and then redeploy
+ >
+ > ```json
+ > {
+ > "networkName": "ghostnet",
+ > "accounts": {
+ > "taqOperatorAccount": {
+ > "publicKey": "edpkvGfYw3LyB1UcCahKQk4rF2tvbMUk8GFiTuMjL75uGXrpvKXhjn",
+ > "publicKeyHash": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb",
+ > "privateKey": "edsk3QoqBuvdamxouPhin7swCvkQNgq4jP5KZPbwWNnwdZpSpJiEbq"
+ > }
+ > }
+ > }
+ > ```
+
+ Your smart contract is ready on Ghostnet!
+
+ ```logs
+ ┌──────────┬──────────────────────────────────────┬───────┐
+ │ Contract │ Address │ Alias │
+ ├──────────┼──────────────────────────────────────┼───────┤
+ │ main.tz │ KT1QjiZcAq63yVSCkfAr9zcFvmKBhQ7nVSWd │ main │
+ └──────────┴──────────────────────────────────────┴───────┘
+ ```
+
+## Summary
+
+That's all for the smart contract. On the next section, you will create the mobile application and connect to your smart contract
+
+When you are ready, continue to [Part 2: Create an Ionic mobile application](./part-2).
diff --git a/docs/tutorials/mobile/part-2.md b/docs/tutorials/mobile/part-2.md
new file mode 100644
index 000000000..ce96dc543
--- /dev/null
+++ b/docs/tutorials/mobile/part-2.md
@@ -0,0 +1,1153 @@
+---
+title: 'Part 2: Create an Ionic mobile application'
+authors: 'Benjamin Fuentes (Marigold)'
+last_update:
+ date: 12 December 2023
+---
+
+A web3 mobile application is not different from a web2 one in terms of its basic functionality and user interface. Both types of applications can run on smartphones, tablets, and other mobile devices, and both can access the internet and provide various services to users. However, a web3 mobile application differs from a web2 one in terms of its underlying architecture and design principles. A web3 mobile application is built on decentralized technologies, such as blockchain, smart contracts, and peer-to-peer networks, that enable more transparency, security, and autonomy for users and developers.
+
+## Create the Mobile app
+
+[Ionic React](https://ionicframework.com/docs/react) is a good hybrid solution for creating mobile applications and compatible with the Typescript version of the [BeaconSDK](https://github.com/airgap-it/beacon-sdk). The behavior is equivalent to a classical web development, so for a web developer the ramp up is easy.
+
+> Beacon: the protocol of communication between the dapp and the wallet.
+
+> Note: As of today, it is not recommended to develop a native dApp in Flutter, React Native or native tools as it requires additional UI works (ex: missing wallet popup mechanism to confirm transactions).
+
+1. Install Ionic:
+
+ ```bash
+ npm install -g @ionic/cli
+ ionic start app blank --type react
+ ```
+
+1. Generate smart contract types from the taqueria plugin:
+
+ This command generates Typescript classes from the smart contract interface definition that is used on the frontend.
+
+ ```bash
+ taq install @taqueria/plugin-contract-types
+ taq generate types ./app/src
+ ```
+
+1. Uninstall the conflicting old jest libraries/react-scripts and install the required Tezos web3 dependencies and Vite framework:
+
+ ```bash
+ cd app
+ npm uninstall -S @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/jest
+ rm -rf src/components src/pages/Home.tsx src/pages/Home.css
+ rm src/setupTests.ts src/App.test.tsx
+ echo '/// ' > src/vite-env.d.ts
+
+ npm install -S @taquito/taquito @taquito/beacon-wallet @airgap/beacon-sdk @tzkt/sdk-api
+ npm install -S -D @airgap/beacon-types vite @vitejs/plugin-react-swc @types/react @types/node @types/react@18.2.42
+ ```
+
+1. Polyfill issues fix:
+
+ > :warning: Polyfill issues fix: Add the following dependencies in order to avoid polyfill issues. The reason is that some dependencies are Node APIs and are not included in browsers.
+
+ 1. Install the missing libraries:
+
+ ```bash
+ npm i -D process buffer crypto-browserify stream-browserify assert stream-http https-browserify os-browserify url path-browserify
+ ```
+
+ 1. Create a new file `nodeSpecific.ts` in the `src` folder of your project:
+
+ ```bash
+ touch src/nodeSpecific.ts
+ ```
+
+ 1. Edit it to look like this:
+
+ ```js
+ import { Buffer } from 'buffer';
+ globalThis.Buffer = Buffer;
+ ```
+
+ 1. Edit the `vite.config.ts` file:
+
+ ```js
+ import react from '@vitejs/plugin-react-swc';
+ import path from 'path';
+ import { defineConfig } from 'vite';
+ // https://vitejs.dev/config/
+ export default ({ command }) => {
+ const isBuild = command === 'build';
+
+ return defineConfig({
+ define: { 'process.env': process.env, global: {} },
+ plugins: [react()],
+ build: {
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ },
+ resolve: {
+ alias: {
+ // dedupe @airgap/beacon-sdk
+ // I almost have no idea why it needs `cjs` on dev and `esm` on build, but this is how it works 🤷♂️
+ '@airgap/beacon-sdk': path.resolve(
+ path.resolve(),
+ `./node_modules/@airgap/beacon-sdk/dist/${
+ isBuild ? 'esm' : 'cjs'
+ }/index.js`
+ ),
+ stream: 'stream-browserify',
+ os: 'os-browserify/browser',
+ util: 'util',
+ process: 'process/browser',
+ buffer: 'buffer',
+ crypto: 'crypto-browserify',
+ assert: 'assert',
+ http: 'stream-http',
+ https: 'https-browserify',
+ url: 'url',
+ path: 'path-browserify',
+ },
+ },
+ });
+ };
+ ```
+
+1. Adapt Ionic for Vite:
+
+ 1. Edit `index.html` to fix the Node buffer issue with `nodeSpecific.ts` file and point to the CSS file:
+
+ ```html
+
+
+
+
+ Ionic App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ```
+
+ 1. Edit **src/main.tsx** to force dark mode and remove React strict mode:
+
+ ```typescript
+ import { createRoot } from 'react-dom/client';
+ import App from './App';
+
+ const container = document.getElementById('root');
+ const root = createRoot(container!);
+
+ // Add or remove the "dark" class based on if the media query matches
+ document.body.classList.add('dark');
+
+ root.render( );
+ ```
+
+ 1. Modify the default **package.json** default scripts to use Vite instead of the default React scripts:
+
+ ```json
+ "scripts": {
+ "dev": "jq -r '\"VITE_CONTRACT_ADDRESS=\" + last(.tasks[]).output[0].address' ../.taq/testing-state.json > .env && vite --host",
+ "ionic:build": "tsc -v && tsc && vite build",
+ "build": " tsc -v && tsc && vite build",
+ "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "ionic:serve": "vite dev --host",
+ "sync": "npm run build && ionic cap sync --no-build"
+ },
+ ```
+
+1. Edit the default application file `src/App.tsx` to configure page routing and add the style:
+
+ ```typescript
+ import {
+ IonApp,
+ IonRouterOutlet,
+ RefresherEventDetail,
+ setupIonicReact,
+ } from '@ionic/react';
+ import { IonReactRouter } from '@ionic/react-router';
+ import { Redirect, Route } from 'react-router-dom';
+
+ /* Core CSS required for Ionic components to work properly */
+ import '@ionic/react/css/core.css';
+
+ /* Basic CSS for apps built with Ionic */
+ import '@ionic/react/css/normalize.css';
+ import '@ionic/react/css/structure.css';
+ import '@ionic/react/css/typography.css';
+
+ /* Optional CSS utils that can be commented out */
+ import '@ionic/react/css/display.css';
+ import '@ionic/react/css/flex-utils.css';
+ import '@ionic/react/css/float-elements.css';
+ import '@ionic/react/css/padding.css';
+ import '@ionic/react/css/text-alignment.css';
+ import '@ionic/react/css/text-transformation.css';
+
+ /* Theme variables */
+ import './theme/variables.css';
+
+ import { NetworkType } from '@airgap/beacon-types';
+ import { BeaconWallet } from '@taquito/beacon-wallet';
+ import { InternalOperationResult } from '@taquito/rpc';
+ import {
+ PollingSubscribeProvider,
+ Subscription,
+ TezosToolkit,
+ } from '@taquito/taquito';
+ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
+ import { MainWalletType, Storage } from './main.types';
+ import { HomeScreen } from './pages/HomeScreen';
+ import { RulesScreen } from './pages/Rules';
+ import { SessionScreen } from './pages/SessionScreen';
+ import { TopPlayersScreen } from './pages/TopPlayersScreen';
+ import {
+ MMap,
+ address,
+ bytes,
+ mutez,
+ nat,
+ timestamp,
+ unit,
+ } from './type-aliases';
+
+ setupIonicReact();
+
+ export class Action implements ActionCisor, ActionPaper, ActionStone {
+ cisor?: unit;
+ paper?: unit;
+ stone?: unit;
+ constructor(cisor?: unit, paper?: unit, stone?: unit) {
+ this.cisor = cisor;
+ this.paper = paper;
+ this.stone = stone;
+ }
+ }
+ export type ActionCisor = { cisor?: unit };
+ export type ActionPaper = { paper?: unit };
+ export type ActionStone = { stone?: unit };
+
+ export type Session = {
+ asleep: timestamp;
+ board: MMap;
+ current_round: nat;
+ decoded_rounds: MMap<
+ nat,
+ Array<{
+ action: { cisor: unit } | { paper: unit } | { stone: unit };
+ player: address;
+ }>
+ >;
+ players: Array;
+ pool: mutez;
+ result: { draw: unit } | { inplay: unit } | { winner: address };
+ rounds: MMap<
+ nat,
+ Array<{
+ action: bytes;
+ player: address;
+ }>
+ >;
+ total_rounds: nat;
+ };
+
+ export type UserContextType = {
+ storage: Storage | null;
+ setStorage: Dispatch>;
+ userAddress: string;
+ setUserAddress: Dispatch>;
+ userBalance: number;
+ setUserBalance: Dispatch>;
+ Tezos: TezosToolkit;
+ wallet: BeaconWallet;
+ mainWalletType: MainWalletType | null;
+ loading: boolean;
+ setLoading: Dispatch>;
+ refreshStorage: (
+ event?: CustomEvent
+ ) => Promise;
+ subReveal: Subscription | undefined;
+ subNewRound: Subscription | undefined;
+ };
+ export const UserContext = React.createContext(null);
+
+ const App: React.FC = () => {
+ const Tezos = new TezosToolkit('https://ghostnet.tezos.marigold.dev');
+
+ const wallet = new BeaconWallet({
+ name: 'Training',
+ preferredNetwork: NetworkType.GHOSTNET,
+ });
+
+ Tezos.setWalletProvider(wallet);
+ Tezos.setStreamProvider(
+ Tezos.getFactory(PollingSubscribeProvider)({
+ shouldObservableSubscriptionRetry: true,
+ pollingIntervalMilliseconds: 1500,
+ })
+ );
+
+ const [userAddress, setUserAddress] = useState('');
+ const [userBalance, setUserBalance] = useState(0);
+ const [storage, setStorage] = useState(null);
+ const [mainWalletType, setMainWalletType] =
+ useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const [subscriptionsDone, setSubscriptionsDone] = useState(false);
+ const [subReveal, setSubReveal] =
+ useState>();
+ const [subNewRound, setSubNewRound] =
+ useState>();
+
+ const refreshStorage = async (
+ event?: CustomEvent
+ ): Promise => {
+ try {
+ if (!userAddress) {
+ const activeAccount = await wallet.client.getActiveAccount();
+ let userAddress: string;
+ if (activeAccount) {
+ userAddress = activeAccount.address;
+ setUserAddress(userAddress);
+ const balance = await Tezos.tz.getBalance(userAddress);
+ setUserBalance(balance.toNumber());
+ }
+ }
+
+ console.log(
+ 'VITE_CONTRACT_ADDRESS:',
+ import.meta.env.VITE_CONTRACT_ADDRESS
+ );
+ const mainWalletType: MainWalletType =
+ await Tezos.wallet.at(
+ import.meta.env.VITE_CONTRACT_ADDRESS
+ );
+ const storage: Storage = await mainWalletType.storage();
+ setMainWalletType(mainWalletType);
+ setStorage(storage);
+ console.log('Storage refreshed');
+
+ event?.detail.complete();
+ } catch (error) {
+ console.log('error refreshing storage', error);
+ }
+ };
+
+ useEffect(() => {
+ try {
+ if (!subscriptionsDone) {
+ const sub = Tezos.stream.subscribeEvent({
+ tag: 'gameStatus',
+ address: import.meta.env.VITE_CONTRACT_ADDRESS!,
+ });
+
+ sub.on('data', (e) => {
+ console.log('on gameStatus event :', e);
+ refreshStorage();
+ });
+
+ setSubReveal(
+ Tezos.stream.subscribeEvent({
+ tag: 'reveal',
+ address: import.meta.env.VITE_CONTRACT_ADDRESS,
+ })
+ );
+
+ setSubNewRound(
+ Tezos.stream.subscribeEvent({
+ tag: 'newRound',
+ address: import.meta.env.VITE_CONTRACT_ADDRESS,
+ })
+ );
+ } else {
+ console.warn(
+ 'Tezos.stream.subscribeEvent already done ... ignoring'
+ );
+ }
+ } catch (e) {
+ console.log('Error with Smart contract event pooling', e);
+ }
+
+ console.log('Tezos.stream.subscribeEvent DONE');
+ setSubscriptionsDone(true);
+ }, []);
+
+ useEffect(() => {
+ if (userAddress) {
+ console.warn('userAddress changed', wallet);
+ (async () => await refreshStorage())();
+ }
+ }, [userAddress]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ export enum PAGES {
+ HOME = '/home',
+ SESSION = '/session',
+ TOPPLAYERS = '/topplayers',
+ RULES = '/rules',
+ }
+
+ export default App;
+ ```
+
+ Explanations:
+
+ - `import "@ionic..."`: Default standard Ionic imports.
+ - `import ... from "@airgap/beacon-types" ... from "@taquito/beacon-wallet" ... from "@taquito/taquito"`: Require libraries to interact with the Tezos node and the wallet.
+ - `export class Action implements ActionCisor, ActionPaper, ActionStone {...}`: Representation of the Ligo variant `Action` in Typescript, which is needed when passing arguments on `Play` function.
+ - `export type Session = {...}`: Taqueria exports the global storage type but not this sub-type from the storage type; it is needed for later, so extract a copy.
+ - `export const UserContext = React.createContext(null)`: Global React context that is passed along pages. More info on React context [here](https://beta.reactjs.org/learn/passing-data-deeply-with-context).
+ - `const refreshStorage = async (event?: CustomEvent): Promise => {...`: A useful function to force the smart contract storage to refresh on React state changes (user balance, state of the game).
+ - `useEffect(() => { ... Tezos.setStreamProvider(...) ... Tezos.stream.subscribeEvent({...`: During application initialization, it configures the wallet, the websocket listening to smart contract events.
+ - ` ... `: Injects the React context to all pages and declares the global routing of the application.
+ - `export enum PAGES { HOME = "/home", ...`: Declaration of the global routes.
+
+1. Add the default theming (CSS, pictures, etc.) via copying the content of the git repository folder named **assets** folder to your local project (considering you cloned the repo and assets folder is on root folder).
+
+ ```bash
+ cp -r ../../assets/* .
+ ```
+
+1. Create two React Button components to connect and disconnect the wallet and add code to fetch the user public hash key and balanceL
+
+ 1. Create the 2 missing component files in the `app` folder:
+
+ ```bash
+ touch src/ConnectWallet.tsx
+ touch src/DisconnectWallet.tsx
+ ```
+
+ 1. In the `ConnectWallet.tsx` file, create a button that creates an instance of the wallet, gets user permissions via a popup, and retrieves account information.
+
+ ```typescript
+ import { NetworkType } from '@airgap/beacon-types';
+ import { IonButton } from '@ionic/react';
+ import { BeaconWallet } from '@taquito/beacon-wallet';
+ import { TezosToolkit } from '@taquito/taquito';
+ import { Dispatch, SetStateAction } from 'react';
+
+ type ButtonProps = {
+ Tezos: TezosToolkit;
+ setUserAddress: Dispatch>;
+ setUserBalance: Dispatch>;
+ wallet: BeaconWallet;
+ };
+
+ const ConnectButton = ({
+ Tezos,
+ setUserAddress,
+ setUserBalance,
+ wallet,
+ }: ButtonProps): JSX.Element => {
+ const connectWallet = async (): Promise => {
+ try {
+ console.log('before requestPermissions');
+
+ await wallet.requestPermissions({
+ network: {
+ type: NetworkType.GHOSTNET,
+ rpcUrl: 'https://ghostnet.tezos.marigold.dev',
+ },
+ });
+ console.log('after requestPermissions');
+
+ // gets user's address
+ const userAddress = await wallet.getPKH();
+ const balance = await Tezos.tz.getBalance(userAddress);
+ setUserBalance(balance.toNumber());
+ setUserAddress(userAddress);
+ } catch (error) {
+ console.log('error connectWallet', error);
+ }
+ };
+
+ return (
+
+ Connect Wallet
+
+ );
+ };
+
+ export default ConnectButton;
+ ```
+
+ 1. In the `DisconnectWallet.tsx` file, create a button that cleans the wallet instance and all linked objects:
+
+ ```typescript
+ import { IonFab, IonFabButton, IonIcon } from '@ionic/react';
+ import { BeaconWallet } from '@taquito/beacon-wallet';
+ import { power } from 'ionicons/icons';
+ import { Dispatch, SetStateAction } from 'react';
+
+ interface ButtonProps {
+ wallet: BeaconWallet;
+ setUserAddress: Dispatch>;
+ setUserBalance: Dispatch>;
+ }
+
+ const DisconnectButton = ({
+ wallet,
+ setUserAddress,
+ setUserBalance,
+ }: ButtonProps): JSX.Element => {
+ const disconnectWallet = async (): Promise => {
+ setUserAddress('');
+ setUserBalance(0);
+ console.log('disconnecting wallet');
+ await wallet.clearActiveAccount();
+ };
+
+ return (
+
+
+
+
+
+ );
+ };
+
+ export default DisconnectButton;
+ ```
+
+ 1. Save both files.
+
+1. Create the missing pages and the error utility class:
+
+ ```bash
+ touch src/pages/HomeScreen.tsx
+ touch src/pages/SessionScreen.tsx
+ touch src/pages/Rules.tsx
+ touch src/pages/TopPlayersScreen.tsx
+ touch src/TransactionInvalidBeaconError.ts
+ ```
+
+ The `TransactionInvalidBeaconError.ts` utility class is used to display human readable message from Beacon errors.
+
+1. Make these updates to the files:
+
+ - HomeScreen.tsx: the home page where you can access all other pages.
+
+ ```typescript
+ import {
+ IonButton,
+ IonButtons,
+ IonContent,
+ IonFooter,
+ IonHeader,
+ IonIcon,
+ IonImg,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonList,
+ IonModal,
+ IonPage,
+ IonRefresher,
+ IonRefresherContent,
+ IonSpinner,
+ IonTitle,
+ IonToolbar,
+ useIonAlert,
+ } from '@ionic/react';
+ import { BigNumber } from 'bignumber.js';
+ import { person } from 'ionicons/icons';
+ import React, { useEffect, useRef, useState } from 'react';
+ import { useHistory } from 'react-router-dom';
+ import { PAGES, Session, UserContext, UserContextType } from '../App';
+ import ConnectButton from '../ConnectWallet';
+ import DisconnectButton from '../DisconnectWallet';
+ import { TransactionInvalidBeaconError } from '../TransactionInvalidBeaconError';
+ import Paper from '../assets/paper-logo.webp';
+ import Scissor from '../assets/scissor-logo.webp';
+ import Stone from '../assets/stone-logo.webp';
+ import XTZLogo from '../assets/xtz.webp';
+ import { SelectMembers } from '../components/TzCommunitySelectMembers';
+ import { address, nat } from '../type-aliases';
+
+ export const HomeScreen: React.FC = () => {
+ const [presentAlert] = useIonAlert();
+ const { push } = useHistory();
+
+ const createGameModal = useRef(null);
+ const selectGameModal = useRef(null);
+ function dismissCreateGameModal() {
+ console.log('dismissCreateGameModal');
+ createGameModal.current?.dismiss();
+ }
+ function dismissSelectGameModal() {
+ selectGameModal.current?.dismiss();
+ const element = document.getElementById('home');
+ setTimeout(() => {
+ return element && element.remove();
+ }, 1000); // Give a little time to properly unmount your previous page before removing the old one
+ }
+
+ const {
+ Tezos,
+ wallet,
+ userAddress,
+ userBalance,
+ storage,
+ mainWalletType,
+ setStorage,
+ setUserAddress,
+ setUserBalance,
+ setLoading,
+ loading,
+ refreshStorage,
+ } = React.useContext(UserContext) as UserContextType;
+
+ const [newPlayer, setNewPlayer] = useState('' as address);
+ const [total_rounds, setTotal_rounds] = useState(
+ new BigNumber(1) as nat
+ );
+ const [myGames, setMyGames] = useState>();
+
+ useEffect(() => {
+ (async () => {
+ if (storage) {
+ const myGames = new Map(); //filtering our games
+ Array.from(storage.sessions.keys()).forEach((key) => {
+ const session = storage.sessions.get(key);
+
+ if (
+ session.players.indexOf(userAddress as address) >= 0 &&
+ 'inplay' in session.result
+ ) {
+ myGames.set(key, session);
+ }
+ });
+ setMyGames(myGames);
+ } else {
+ console.log('storage is not ready yet');
+ }
+ })();
+ }, [storage]);
+
+ const createSession = async (
+ e: React.MouseEvent
+ ) => {
+ console.log('createSession');
+ e.preventDefault();
+
+ try {
+ setLoading(true);
+ const op = await mainWalletType?.methods
+ .createSession(total_rounds, [userAddress as address, newPlayer])
+ .send();
+ await op?.confirmation();
+ const newStorage = await mainWalletType?.storage();
+ setStorage(newStorage!);
+ setLoading(false);
+ dismissCreateGameModal();
+ setTimeout(
+ () => push(PAGES.SESSION + '/' + storage?.next_session.toString()),
+ 500
+ );
+ //it was the id created
+ console.log('newStorage', newStorage);
+ } catch (error) {
+ console.table(`Error: ${JSON.stringify(error, null, 2)}`);
+ const tibe: TransactionInvalidBeaconError =
+ new TransactionInvalidBeaconError(error);
+ presentAlert({
+ header: 'Error',
+ message: tibe.data_message,
+ buttons: ['Close'],
+ });
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ return (
+
+
+
+ Shifumi
+
+
+
+
+
+
+
+ {loading ? (
+
+
+ Refreshing ...
+
+
+
+ ) : (
+
+ {!userAddress ? (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+
+ {userAddress}
+
+
+
+
+
+ {userBalance / 1000000}
+
+
+
+
+
+
+
+
+
+
+ New game
+
+
+
+
+
+ dismissCreateGameModal()}
+ >
+ Cancel
+
+
+ New Game
+
+ createSession(e)}
+ id="createGameModal"
+ >
+ Create
+
+
+
+
+
+ How many total rounds ?
+
+
+
+ {
+ if (str.detail.value === undefined) return;
+ setTotal_rounds(
+ new BigNumber(str.target.value) as nat
+ );
+ }}
+ value={total_rounds.toString()}
+ placeholder="total_rounds"
+ type="number"
+ label="Total Rounds"
+ />
+
+
+ Choose your opponent player
+
+
+
+
+ {
+ if (str.detail.value === undefined) return;
+ setNewPlayer(str.detail.value as address);
+ }}
+ labelPlacement="floating"
+ class="address"
+ value={newPlayer}
+ placeholder="...tz1"
+ type="text"
+ label="Tezos Address "
+ />
+
+
+
+
+
+ Join game
+
+
+
+
+
+ dismissSelectGameModal()}
+ >
+ Cancel
+
+
+ Select Game
+
+
+
+
+ {myGames
+ ? Array.from(myGames.entries()).map(([key, _]) => (
+
+ {'Game n°' + key.toString()}
+
+ ))
+ : []}
+
+
+
+
+
+ Top Players
+
+
+ )}
+
+ )}
+
+
+
+
+
+ Rules
+
+
+
+
+
+ {userAddress ? (
+
+ ) : (
+ <>>
+ )}
+
+ );
+ };
+ ```
+
+ Explanation:
+
+ - `const createGameModal`: The popup to create a new game.
+ - `const selectGameModal`: The popup to select a game to join.
+ - `const [newPlayer, setNewPlayer] = useState("" as address)`: Used on the `New Game` popup form to add an opponent.
+ - `const [total_rounds, setTotal_rounds] = useState(new BigNumber(1) as nat)`: Used on the `New Game` popup form to set number of round for one game.
+ - `const [myGames, setMyGames] = useState>()`: Used on the `Join Game` popup window to display the games created or with invitation.
+ - `Array.from(storage.sessions.keys()).forEach((key) => { ... if (session.players.indexOf(userAddress as address) >= 0 && "inplay" in session.result ...`: On storage change event, fetch and filter only games which the user can join and play (that is, with `inplay` status and where user appears on the player list).
+ - `const createSession = async (...) => { ... const op = await mainWalletType!.methods.createSession([userAddress as address, newPlayer], total_rounds).send(); ... `: This function calls the smart contract entrypoint passing these arguments: current user address, opponent address, and total rounds. Then it redirects to the newly created game page.
+ - `{... {
+ return ;
+ };
+ ```
+
+ You will add more to this file later.
+
+ - TopPlayersScreen.tsx: The player ranking page.
+
+ ```typescript
+ import { IonPage } from '@ionic/react';
+ import React from 'react';
+
+ export const TopPlayersScreen: React.FC = () => {
+ return ;
+ };
+ ```
+
+ You will add more to this file later.
+
+ - Rules.tsx: Just some information about game rules.
+
+ ```typescript
+ import {
+ IonButton,
+ IonButtons,
+ IonContent,
+ IonHeader,
+ IonImg,
+ IonItem,
+ IonList,
+ IonPage,
+ IonTitle,
+ IonToolbar,
+ } from '@ionic/react';
+ import React from 'react';
+ import { useHistory } from 'react-router-dom';
+ import Clock from '../assets/clock.webp';
+ import Legend from '../assets/legend.webp';
+ import Paper from '../assets/paper-logo.webp';
+ import Scissor from '../assets/scissor-logo.webp';
+ import Stone from '../assets/stone-logo.webp';
+
+ export const RulesScreen: React.FC = () => {
+ const { goBack } = useHistory();
+
+ /* 2. Get the param */
+ return (
+
+
+
+
+ Back
+
+ Rules
+
+
+
+
+
+
+
+ Stone (Clenched Fist). Rock beats the scissors by hitting it
+
+
+
+ Paper (open and extended hand) . Paper wins over stone by enveloping
+ it
+
+
+
+ Scissors (closed hand with the two fingers) . Scissors wins paper
+ cutting it
+
+
+
+
+ If you are inactive for more than 10 minutes your opponent can
+ claim the victory
+
+
+
+
+
+ Won round
+ Lost round
+ Draw
+ Current Round
+ Missing Rounds
+
+
+
+
+
+
+ );
+ };
+ ```
+
+ - TransactionInvalidBeaconError.ts: The utility class that formats Beacon errors.
+
+ ```typescript
+ export class TransactionInvalidBeaconError {
+ name: string;
+ title: string;
+ message: string;
+ description: string;
+ data_contract_handle: string;
+ data_expected_form: string;
+ data_message: string;
+
+ /**
+ *
+ * @param transactionInvalidBeaconError {
+ "name": "UnknownBeaconError",
+ "title": "Aborted",
+ "message": "[ABORTED_ERROR]:The action was aborted by the user.",
+ "description": "The action was aborted by the user."
+ }
+ */
+
+ constructor(transactionInvalidBeaconError: any) {
+ this.name = transactionInvalidBeaconError.name;
+ this.title = transactionInvalidBeaconError.title;
+ this.message = transactionInvalidBeaconError.message;
+ this.description = transactionInvalidBeaconError.description;
+ this.data_contract_handle = '';
+ this.data_expected_form = '';
+ this.data_message = this.message;
+ if (transactionInvalidBeaconError.data !== undefined) {
+ let dataArray = Array.from(
+ new Map(
+ Object.entries(transactionInvalidBeaconError.data)
+ ).values()
+ );
+ let contract_handle = dataArray.find(
+ (obj) => obj.contract_handle !== undefined
+ );
+ this.data_contract_handle =
+ contract_handle !== undefined
+ ? contract_handle.contract_handle
+ : '';
+ let expected_form = dataArray.find(
+ (obj) => obj.expected_form !== undefined
+ );
+ this.data_expected_form =
+ expected_form !== undefined
+ ? expected_form.expected_form +
+ ':' +
+ expected_form.wrong_expression.string
+ : '';
+ this.data_message =
+ (this.data_contract_handle
+ ? 'Error on contract: ' + this.data_contract_handle + ' '
+ : '') +
+ (this.data_expected_form
+ ? 'error: ' + this.data_expected_form + ' '
+ : '');
+ }
+ }
+ }
+ ```
+
+1. Test the application:
+
+ To test in web mode, run this command:
+
+ ```bash
+ npm run dev
+ ```
+
+ Make sure that your wallet is has some tez on Ghostnet and click on the **Connect** button.
+
+ > Note: If you don't have tokens, to get some free XTZ on Ghostnet, follow this link to the [faucet](https://faucet.marigold.dev/).
+
+ On the popup, select your wallet, then your account and connect.
+
+ You are _logged_.
+
+ Optional: Click the Disconnect button to test the logout.
+
+## Summary
+
+You have a mobile application where you can connect and disconnect a wallet, some default UI components and styles but not yet an interaction with your smart contract.
+The next step is to be able to create a game, join a game and play a session.
+
+When you are ready, continue to [Part 3: Create the game pages](./part-3).
diff --git a/docs/tutorials/mobile/part-3.md b/docs/tutorials/mobile/part-3.md
new file mode 100644
index 000000000..2391571c4
--- /dev/null
+++ b/docs/tutorials/mobile/part-3.md
@@ -0,0 +1,829 @@
+---
+title: 'Part 3: Create the game pages'
+authors: 'Benjamin Fuentes (Marigold)'
+last_update:
+ date: 12 December 2023
+---
+
+In this section, you will create the pages to:
+
+- Create a game: you interact with the modal `createGameModal` from the `HomeScreen.tsx` page to create a game session.
+- Join a game: it redirects you to an existing game session on the `SessionScreen.tsx` page. This modal is coded on the `HomeScreen.tsx` page.
+- Play a session: when you are in a game session against someone, you can play some action
+ - Choose a move: Scissor, Stone, or Paper
+ - Reveal your move to resolve the game round. A game session can have several rounds.
+- Visualize the top player results.
+
+## Play a game session
+
+1. Click the `New Game` button from the home page and then create a new game.
+
+1. Confirm the operation with your wallet.
+
+ You are redirected the new game session page (that is blank page right now).
+
+ > Note: you can look at the code of the modal `createGameModal` from the `HomeScreen.tsx` page to understand how it works.
+
+1. Edit the file `./src/SessionScreen.tsx` to look like this:
+
+ ```typescript
+ import {
+ IonButton,
+ IonButtons,
+ IonContent,
+ IonFooter,
+ IonHeader,
+ IonIcon,
+ IonImg,
+ IonItem,
+ IonLabel,
+ IonList,
+ IonPage,
+ IonRefresher,
+ IonRefresherContent,
+ IonSpinner,
+ IonTitle,
+ IonToolbar,
+ useIonAlert,
+ } from '@ionic/react';
+ import { MichelsonV1ExpressionBase, PackDataParams } from '@taquito/rpc';
+ import { MichelCodecPacker } from '@taquito/taquito';
+ import { BigNumber } from 'bignumber.js';
+ import * as crypto from 'crypto';
+ import { eye, stopCircle } from 'ionicons/icons';
+ import React, { useEffect, useState } from 'react';
+ import { RouteComponentProps, useHistory } from 'react-router-dom';
+ import { Action, PAGES, UserContext, UserContextType } from '../App';
+ import { TransactionInvalidBeaconError } from '../TransactionInvalidBeaconError';
+ import Paper from '../assets/paper-logo.webp';
+ import Scissor from '../assets/scissor-logo.webp';
+ import Stone from '../assets/stone-logo.webp';
+ import { bytes, nat, unit } from '../type-aliases';
+
+ export enum STATUS {
+ PLAY = 'Play !',
+ WAIT_YOUR_OPPONENT_PLAY = 'Wait for your opponent move',
+ REVEAL = 'Reveal your choice now',
+ WAIT_YOUR_OPPONENT_REVEAL = 'Wait for your opponent to reveal his choice',
+ FINISHED = 'Game ended',
+ }
+
+ type SessionScreenProps = RouteComponentProps<{
+ id: string;
+ }>;
+
+ export const SessionScreen: React.FC = ({ match }) => {
+ const [presentAlert] = useIonAlert();
+ const { goBack } = useHistory();
+
+ const id: string = match.params.id;
+
+ const {
+ Tezos,
+ userAddress,
+ storage,
+ mainWalletType,
+ setStorage,
+ setLoading,
+ loading,
+ refreshStorage,
+ subReveal,
+ subNewRound,
+ } = React.useContext(UserContext) as UserContextType;
+
+ const [status, setStatus] = useState();
+ const [remainingTime, setRemainingTime] = useState(10 * 60);
+ const [sessionEventRegistrationDone, setSessionEventRegistrationDone] =
+ useState(false);
+
+ const registerSessionEvents = async () => {
+ if (!sessionEventRegistrationDone) {
+ if (subReveal)
+ subReveal.on('data', async (e) => {
+ console.log('on reveal event', e, id, UserContext);
+ if (
+ (!e.result.errors || e.result.errors.length === 0) &&
+ (e.payload as MichelsonV1ExpressionBase).int === id
+ ) {
+ await revealPlay();
+ } else
+ console.warn(
+ 'Warning: here we ignore this transaction event for session ',
+ id
+ );
+ });
+
+ if (subNewRound)
+ subNewRound.on('data', (e) => {
+ if (
+ (!e.result.errors || e.result.errors.length === 0) &&
+ (e.payload as MichelsonV1ExpressionBase).int === id
+ ) {
+ console.log('on new round event:', e);
+ refreshStorage();
+ } else
+ console.log('Warning: here we ignore this transaction event', e);
+ });
+
+ console.log(
+ 'registerSessionEvents registered',
+ subReveal,
+ subNewRound
+ );
+ setSessionEventRegistrationDone(true);
+ }
+ };
+
+ const buildSessionStorageKey = (
+ userAddress: string,
+ sessionNumber: number,
+ roundNumber: number
+ ): string => {
+ return (
+ import.meta.env.VITE_CONTRACT_ADDRESS +
+ '-' +
+ userAddress +
+ '-' +
+ sessionNumber +
+ '-' +
+ roundNumber
+ );
+ };
+
+ const buildSessionStorageValue = (
+ secret: number,
+ action: Action
+ ): string => {
+ return (
+ secret +
+ '-' +
+ (action.cisor ? 'cisor' : action.paper ? 'paper' : 'stone')
+ );
+ };
+
+ const extractSessionStorageValue = (
+ value: string
+ ): { secret: number; action: Action } => {
+ const actionStr = value.split('-')[1];
+ return {
+ secret: Number(value.split('-')[0]),
+ action:
+ actionStr === 'cisor'
+ ? new Action(true as unit, undefined, undefined)
+ : actionStr === 'paper'
+ ? new Action(undefined, true as unit, undefined)
+ : new Action(undefined, undefined, true as unit),
+ };
+ };
+
+ useEffect(() => {
+ if (storage) {
+ const session = storage?.sessions.get(new BigNumber(id) as nat);
+ console.log(
+ 'Session has changed',
+ session,
+ 'round',
+ session?.current_round.toNumber(),
+ 'session.decoded_rounds.get(session.current_round)',
+ session?.decoded_rounds.get(session?.current_round)
+ );
+ if (
+ session &&
+ ('winner' in session.result || 'draw' in session.result)
+ ) {
+ setStatus(STATUS.FINISHED);
+ } else if (session) {
+ if (
+ session.decoded_rounds &&
+ session.decoded_rounds.get(session.current_round) &&
+ session.decoded_rounds.get(session.current_round).length === 1 &&
+ session.decoded_rounds.get(session.current_round)[0].player ===
+ userAddress
+ ) {
+ setStatus(STATUS.WAIT_YOUR_OPPONENT_REVEAL);
+ } else if (
+ session.rounds &&
+ session.rounds.get(session.current_round) &&
+ session.rounds.get(session.current_round).length === 2
+ ) {
+ setStatus(STATUS.REVEAL);
+ } else if (
+ session.rounds &&
+ session.rounds.get(session.current_round) &&
+ session.rounds.get(session.current_round).length === 1 &&
+ session.rounds.get(session.current_round)[0].player === userAddress
+ ) {
+ setStatus(STATUS.WAIT_YOUR_OPPONENT_PLAY);
+ } else {
+ setStatus(STATUS.PLAY);
+ }
+ }
+
+ (async () => await registerSessionEvents())();
+ } else {
+ console.log('Wait parent to init storage ...');
+ }
+ }, [storage?.sessions.get(new BigNumber(id) as nat)]);
+
+ //setRemainingTime
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const diff = Math.round(
+ (new Date(
+ storage?.sessions.get(new BigNumber(id) as nat).asleep!
+ ).getTime() -
+ Date.now()) /
+ 1000
+ );
+
+ if (diff <= 0) {
+ setRemainingTime(0);
+ } else {
+ setRemainingTime(diff);
+ }
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [storage?.sessions.get(new BigNumber(id) as nat)]);
+
+ const play = async (action: Action) => {
+ const session_id = new BigNumber(id) as nat;
+ const current_session = storage?.sessions.get(session_id);
+ try {
+ setLoading(true);
+ const secret = Math.round(Math.random() * 63); //FIXME it should be 654843, but we limit the size of the output hexa because expo-crypto is buggy
+ // see https://forums.expo.dev/t/how-to-hash-buffer-with-expo-for-an-array-reopen/64587 or https://github.com/expo/expo/issues/20706 );
+ localStorage.setItem(
+ buildSessionStorageKey(
+ userAddress,
+ Number(id),
+ storage!.sessions
+ .get(new BigNumber(id) as nat)
+ .current_round.toNumber()
+ ),
+ buildSessionStorageValue(secret, action)
+ );
+ console.log('PLAY - pushing to session storage ', secret, action);
+ const encryptedAction = await create_bytes(action, secret);
+ console.log(
+ 'encryptedAction',
+ encryptedAction,
+ 'session_id',
+ session_id,
+ 'current_round',
+ current_session?.current_round
+ );
+
+ const preparedCall = mainWalletType?.methods.play(
+ session_id,
+ current_session!.current_round,
+
+ encryptedAction
+ );
+
+ const { gasLimit, storageLimit, suggestedFeeMutez } =
+ await Tezos.estimate.transfer({
+ ...preparedCall!.toTransferParams(),
+ amount: 1,
+ mutez: false,
+ });
+
+ console.log({ gasLimit, storageLimit, suggestedFeeMutez });
+ const op = await preparedCall!.send({
+ gasLimit: gasLimit * 2, //we take a margin +1000 for an eventual event in case of paralell execution
+ fee: suggestedFeeMutez * 2,
+ storageLimit: storageLimit,
+ amount: 1,
+ mutez: false,
+ });
+
+ await op?.confirmation();
+ const newStorage = await mainWalletType?.storage();
+ setStorage(newStorage!);
+ setLoading(false);
+ console.log('newStorage', newStorage);
+ } catch (error) {
+ console.table(`Error: ${JSON.stringify(error, null, 2)}`);
+ const tibe: TransactionInvalidBeaconError =
+ new TransactionInvalidBeaconError(error);
+ presentAlert({
+ header: 'Error',
+ message: tibe.data_message,
+ buttons: ['Close'],
+ });
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ const revealPlay = async () => {
+ const session_id = new BigNumber(id) as nat;
+
+ //force refresh in case of events
+ const storage = await mainWalletType?.storage();
+
+ const current_session = storage!.sessions.get(session_id)!;
+
+ console.warn(
+ 'refresh storage because events can trigger it outisde react scope ...',
+ userAddress,
+ current_session.current_round
+ );
+
+ //fecth from session storage
+ const secretActionStr = localStorage.getItem(
+ buildSessionStorageKey(
+ userAddress,
+ session_id.toNumber(),
+ current_session!.current_round.toNumber()
+ )
+ );
+
+ if (!secretActionStr) {
+ presentAlert({
+ header: 'Internal error',
+ message:
+ 'You lose the session/round ' +
+ session_id +
+ '/' +
+ current_session!.current_round.toNumber() +
+ ' storage, no more possible to retrieve secrets, stop Session please',
+ buttons: ['Close'],
+ });
+ setLoading(false);
+ return;
+ }
+
+ const secretAction = extractSessionStorageValue(secretActionStr);
+ console.log('REVEAL - Fetch from session storage', secretAction);
+
+ try {
+ setLoading(true);
+ const encryptedAction = await packAction(secretAction.action);
+
+ const preparedCall = mainWalletType?.methods.revealPlay(
+ session_id,
+ current_session?.current_round!,
+
+ encryptedAction as bytes,
+ new BigNumber(secretAction.secret) as nat
+ );
+
+ const { gasLimit, storageLimit, suggestedFeeMutez } =
+ await Tezos.estimate.transfer(preparedCall!.toTransferParams());
+
+ //console.log({ gasLimit, storageLimit, suggestedFeeMutez });
+ const op = await preparedCall!.send({
+ gasLimit: gasLimit * 3,
+ fee: suggestedFeeMutez * 2,
+ storageLimit: storageLimit * 4, //we take a margin in case of paralell execution
+ });
+ await op?.confirmation();
+ const newStorage = await mainWalletType?.storage();
+ setStorage(newStorage!);
+ setLoading(false);
+ console.log('newStorage', newStorage);
+ } catch (error) {
+ console.table(`Error: ${JSON.stringify(error, null, 2)}`);
+ const tibe: TransactionInvalidBeaconError =
+ new TransactionInvalidBeaconError(error);
+ presentAlert({
+ header: 'Error',
+ message: tibe.data_message,
+ buttons: ['Close'],
+ });
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ /** Pack an action variant to bytes. Same is Pack.bytes() */
+ async function packAction(action: Action): Promise {
+ const p = new MichelCodecPacker();
+ const actionbytes: PackDataParams = {
+ data: action.stone
+ ? { prim: 'Left', args: [{ prim: 'Unit' }] }
+ : action.paper
+ ? {
+ prim: 'Right',
+ args: [{ prim: 'Left', args: [{ prim: 'Unit' }] }],
+ }
+ : {
+ prim: 'Right',
+ args: [{ prim: 'Right', args: [{ prim: 'Unit' }] }],
+ },
+ type: {
+ prim: 'Or',
+ annots: ['%action'],
+ args: [
+ { prim: 'Unit', annots: ['%stone'] },
+ {
+ prim: 'Or',
+ args: [
+ { prim: 'Unit', annots: ['%paper'] },
+ { prim: 'Unit', annots: ['%cisor'] },
+ ],
+ },
+ ],
+ },
+ };
+ return (await p.packData(actionbytes)).packed;
+ }
+
+ /** Pack an pair [actionBytes,secret] to bytes. Same is Pack.bytes() */
+ async function packActionBytesSecret(
+ actionBytes: bytes,
+ secret: number
+ ): Promise {
+ const p = new MichelCodecPacker();
+ const actionBytesSecretbytes: PackDataParams = {
+ data: {
+ prim: 'Pair',
+ args: [{ bytes: actionBytes }, { int: secret.toString() }],
+ },
+ type: {
+ prim: 'pair',
+ args: [
+ {
+ prim: 'bytes',
+ },
+ { prim: 'nat' },
+ ],
+ },
+ };
+ return (await p.packData(actionBytesSecretbytes)).packed;
+ }
+
+ const stopSession = async () => {
+ try {
+ setLoading(true);
+ const op = await mainWalletType?.methods
+ .stopSession(new BigNumber(id) as nat)
+ .send();
+ await op?.confirmation(2);
+ const newStorage = await mainWalletType?.storage();
+ setStorage(newStorage!);
+ setLoading(false);
+ console.log('newStorage', newStorage);
+ } catch (error) {
+ console.table(`Error: ${JSON.stringify(error, null, 2)}`);
+ const tibe: TransactionInvalidBeaconError =
+ new TransactionInvalidBeaconError(error);
+ presentAlert({
+ header: 'Error',
+ message: tibe.data_message,
+ buttons: ['Close'],
+ });
+ setLoading(false);
+ }
+ setLoading(false);
+ };
+
+ const create_bytes = async (
+ action: Action,
+ secret: number
+ ): Promise => {
+ const actionBytes = (await packAction(action)) as bytes;
+ console.log('actionBytes', actionBytes);
+ const bytes = (await packActionBytesSecret(
+ actionBytes,
+ secret
+ )) as bytes;
+ console.log('bytes', bytes);
+
+ /* correct implementation with a REAL library */
+ const encryptedActionSecret = crypto
+ .createHash('sha512')
+ .update(Buffer.from(bytes, 'hex'))
+ .digest('hex') as bytes;
+
+ console.log('encryptedActionSecret', encryptedActionSecret);
+ return encryptedActionSecret;
+ };
+
+ const getFinalResult = (): string | undefined => {
+ if (storage) {
+ const result = storage.sessions.get(new BigNumber(id) as nat).result;
+ if ('winner' in result && result.winner === userAddress) return 'win';
+ if ('winner' in result && result.winner !== userAddress) return 'lose';
+ if ('draw' in result) return 'draw';
+ }
+ };
+
+ const isDesktop = () => {
+ const { innerWidth } = window;
+ if (innerWidth > 800) return true;
+ else return false;
+ };
+
+ return (
+
+
+
+
+ Back
+
+ Game n°{id}
+
+
+
+
+
+
+ {loading ? (
+
+
+ Refreshing ...
+
+
+
+ ) : (
+ <>
+
+ {status !== STATUS.FINISHED ? (
+ Status: {status}
+ ) : (
+ ''
+ )}
+
+
+ Opponent:{' '}
+ {storage?.sessions
+ .get(new BigNumber(id) as nat)
+ .players.find((userItem) => userItem !== userAddress)}
+
+
+
+ {status !== STATUS.FINISHED ? (
+
+ Round:
+ {Array.from(
+ Array(
+ storage?.sessions
+ .get(new BigNumber(id) as nat)
+ .total_rounds.toNumber()
+ ).keys()
+ ).map((roundId) => {
+ const currentRound: number = storage
+ ? storage?.sessions
+ .get(new BigNumber(id) as nat)
+ .current_round?.toNumber() - 1
+ : 0;
+ const roundwinner = storage?.sessions
+ .get(new BigNumber(id) as nat)
+ .board.get(new BigNumber(roundId + 1) as nat);
+
+ return (
+ currentRound
+ ? 'missing'
+ : !roundwinner && roundId === currentRound
+ ? 'current'
+ : !roundwinner
+ ? 'draw'
+ : roundwinner.Some === userAddress
+ ? 'win'
+ : 'lose'
+ }
+ >
+ );
+ })}
+
+ ) : (
+ ''
+ )}
+
+ {status !== STATUS.FINISHED ? (
+
+ {'Remaining time:' + remainingTime + ' s'}
+
+ ) : (
+ ''
+ )}
+
+
+ {status === STATUS.FINISHED ? (
+
+ ) : (
+ ''
+ )}
+
+ {status === STATUS.PLAY ? (
+
+
+
+ play(new Action(true as unit, undefined, undefined))
+ }
+ >
+
+
+
+
+
+ play(new Action(undefined, true as unit, undefined))
+ }
+ >
+
+
+
+
+
+ play(new Action(undefined, undefined, true as unit))
+ }
+ >
+
+
+
+
+ ) : (
+ ''
+ )}
+
+ {status === STATUS.REVEAL ? (
+ revealPlay()}>
+
+ Reveal
+
+ ) : (
+ ''
+ )}
+ {remainingTime === 0 && status !== STATUS.FINISHED ? (
+ stopSession()}>
+
+ Claim victory
+
+ ) : (
+ ''
+ )}
+ >
+ )}
+
+
+
+
+
+ Rules
+
+
+
+
+
+ );
+ };
+ ```
+
+ Explanations:
+
+ - `export enum STATUS {...`: This enum is used to guess the actual status of the game based on different field values. It gives the connected user the next action to do, and so controls the display of the buttons.
+ - `const subReveal = Tezos.stream.subscribeEvent({tag: "reveal",...`: Websocket subscription to the smart contract `reveal` event. When it is time to reveal, it can trigger the action from the mobile app without asking the user to click the button.
+ - `const subNewRound = Tezos.stream.subscribeEvent({tag: "newRound",...`: Websocket subscription to smart contract `newround` event. When a new round is ready, this event notifies the mobile to refresh the current game so the player can play the next round.
+ - `const buildSessionStorageKey ...`: This function is a helper to store on browser storage a unique secret key of the player. This secret is a salt that is added to encrypt the Play action and then to decrypt the Reveal action.
+ - `const buildSessionStorageValue ...`: Same as above but for the value stored as a string.
+ - `const play = async (action: Action) => { ... `: Play action. It creates a player secret for this Play action randomly `Math.round(Math.random() * 63)` and stores it on the browser storage `localStorage.setItem(buildSessionStorageKey(...`. Then it packs and encrypts the Play action calling `create_bytes(action, secret)`. It estimates the cost of the transaction and adds an extra amount for the event cost `mainWalletType!.methods.play(encryptedAction,current_session!.current_round,session_id) ... Tezos.estimate.transfer(...) ... preparedCall.send({gasLimit: gasLimit + 1000, ...`. It asks for 1 XTZ from each player doing a Play action. This money is staked on the contract and freed when the game is ended. The Shifumi game itself does not take any extra fees by default. Only players win or lose money.
+ - `const revealPlay = async () => {...`: Reveal action. It fetches the secret from `localStorage.getItem(...`, then it packs the secret action and reveals the secret: `mainWalletType!.methods.revealPlay(encryptedAction as bytes,new BigNumber(secretAction.secret) as nat,current_session!.current_round,session_id);`. It adds again an extra gas limit `gasLimit: gasLimit * 3`. It increases the gas limit because if two players reveal actions on the same block, the primary estimation of gas made by the wallet is not enough. The reason is that the execution of the second reveal play action executes another business logic because the first action is modifying the initial state, so the estimation at this time (with this previous state) is no longer valid.
+ - `const getFinalResult`: Based on some fields, it gives the final status of the game when it is ended. When the game is ended the winner gets the money staked by the loser. In case of a draw, the staked money is sent back to the players.
+ - `const stopSession = async () => {...`: There is a countdown of 10 minutes. If no player wants to play any more and the game is unfinished, someone can claim the victory and close the game calling `mainWalletType!.methods.stopSession(`. The smart contract looks at different configurations to guess if there is someone guilty or it is just a draw because no one wants to play any more. Gains are sent to the winner or in a case of draw, the tez is sent back to players.
+
+ When the page refreshes, you can see the game session.
+
+1. Create the Top player score page:
+
+ The last step is to see the score of all players.
+
+ Edit `TopPlayersScreen.tsx` to look like this:
+
+ ```typescript
+ import {
+ IonButton,
+ IonButtons,
+ IonCol,
+ IonContent,
+ IonGrid,
+ IonHeader,
+ IonImg,
+ IonPage,
+ IonRefresher,
+ IonRefresherContent,
+ IonRow,
+ IonTitle,
+ IonToolbar,
+ } from '@ionic/react';
+ import React, { useEffect, useState } from 'react';
+ import { useHistory } from 'react-router-dom';
+ import { UserContext, UserContextType } from '../App';
+ import Ranking from '../assets/ranking.webp';
+ import { nat } from '../type-aliases';
+
+ export const TopPlayersScreen: React.FC = () => {
+ const { goBack } = useHistory();
+ const { storage, refreshStorage } = React.useContext(
+ UserContext
+ ) as UserContextType;
+
+ const [ranking, setRanking] = useState>(new Map());
+
+ useEffect(() => {
+ (async () => {
+ if (storage) {
+ const ranking = new Map(); //force refresh
+ Array.from(storage.sessions.keys()).forEach((key: nat) => {
+ const result = storage.sessions.get(key).result;
+ if ('winner' in result) {
+ const winner = result.winner;
+ let score = ranking.get(winner);
+ if (score) score++;
+ else score = 1;
+ ranking.set(winner, score);
+ }
+ });
+
+ setRanking(ranking);
+ } else {
+ console.log('storage is not ready yet');
+ }
+ })();
+ }, [storage]);
+
+ /* 2. Get the param */
+ return (
+
+
+
+
+ Back
+
+ Top Players
+
+
+
+
+
+
+
+
+
+
+
+
+ Address
+
+ Won
+
+
+
+ {ranking && ranking.size > 0
+ ? Array.from(ranking).map(([address, count]) => (
+
+ {address}
+
+ {count}
+
+
+ ))
+ : []}
+
+
+
+ );
+ };
+ ```
+
+ Explanations:
+
+ - `let ranking = new Map()`: It prepares a map to count the score for each winner. Looping through all sessions with `storage.sessions.keys()).forEach`, it takes only where there is a winner `if ("winner" in result)` then it increments the score `if (score) score++;else score = 1` and pushes it to the map `ranking.set(winner, score);`.
+
+ All pages are ready. The Game is done!
+
+## Summary
+
+You have successfully create a Web3 game that runs 100% on-chain.
+The next step is to build and distribute your game as an Android app.
+
+When you are ready, continue to [Part 4: Publish on the Android store](./part-4).
diff --git a/docs/tutorials/mobile/part-4.md b/docs/tutorials/mobile/part-4.md
new file mode 100644
index 000000000..1455211e0
--- /dev/null
+++ b/docs/tutorials/mobile/part-4.md
@@ -0,0 +1,134 @@
+---
+title: 'Part 4: Publish on the Android store'
+authors: 'Benjamin Fuentes (Marigold)'
+last_update:
+ date: 12 December 2023
+---
+
+Your game will be more successful if you publish it on the Android or Apple store. A recommendation is to start with Android as it is easy and cheaper than the iOS version.
+
+## Bundle for Android
+
+1. Install the [Android SDK](https://developer.android.com/about/versions/13/setup-sdk).
+
+1. Modify the name of your app, open the `capacitor.config.json` file and change the `"appId":"dev.marigold.shifumi"` and `"appName": "Tezos Shifumi"` properties.
+
+1. Hack: to build on Android, change `vite.config.ts` to remove the `global` field from the configuration.
+
+ ```javascript
+ export default defineConfig({
+ define: {
+ "process.env": process.env,
+ //global: {},
+ },
+ ```
+
+1. In the `ionic.config.json` file, change the Ionic config from React to a custom type build.
+
+ ```json
+ {
+ "name": "shifumi",
+ "integrations": {
+ "capacitor": {}
+ },
+ "type": "custom"
+ }
+ ```
+
+1. Stay in the app folder and prepare the Android release. These lines copy all files to android folder and the images resources used by the store.
+
+ ```bash
+ ionic capacitor add android
+ ionic capacitor copy android
+ npm install -g cordova-res
+ cordova-res android --skip-config --copy
+ ionic capacitor sync android
+ ionic capacitor update android
+ ```
+
+ Open Android Studio and do a `Build` or `Make Project` action.
+
+ > Note 1: in case of broken Gradle: `ionic capacitor sync android` and click **sync** on **Android studio > build**.
+
+ > Note 2: If you have `WSL2` and difficulties to run an emulator on it, install Android studio on Windows and build, test, and package all on Windows. Push your files to your git repo, and check the `.gitignore` file for the `android` folder to verify that there are no filters on assets.
+ >
+ > 1. Comment the end lines in the file `ionic.config.json`:
+ >
+ > ```bash
+ > # Cordova plugins for Capacitor
+ > #capacitor-cordova-android-plugins
+ >
+ > # Copied web assets
+ > #app/src/main/assets/public
+ >
+ > # Generated Config files
+ > #app/src/main/assets/capacitor.config.json
+ > #app/src/main/assets/capacitor.plugins.json
+ > #app/src/main/res/xml/config.xml
+ > ```
+ >
+ > 1. Comment out the `node_modules` and `dist` in the `.gitignore` file at your root project because it requires files from @capacitor and you need to install these libraries:
+ >
+ > ```bash
+ > #node_modules/
+ > #/dist
+ > ```
+ >
+ > 1. Force it to be included on committed files: `git add -f android/app/src/main/assets/ ; git add -f android/capacitor-cordova-android-plugins/ ; git add -f node_modules ;` and push to git.
+ > 1. Try the `Build` or `Make Project` action on Android Studio again.
+
+ ![build.png](/img/tutorials/mobile-build.png)
+
+ Start the emulator of your choice (or a physical device) and click `Run app`.
+
+ ![run.png](/img/tutorials/mobile-run.png)
+
+ Some mobile wallets do not work with emulators, so consider using a web wallet like Kukai.
+
+ ![kukai.png](/img/tutorials/mobile-kukai.png)
+
+ When you are connected, you can start a new game.
+
+ ![home.png](/img/tutorials/mobile-home.png)
+
+1. Invite Alice to play, click the address of the opponent player, and enter this code on your Android Studio terminal:
+
+ ```bash
+ adb shell input text "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
+ ```
+
+ ![alice.png](/img/tutorials/mobile-alice.png)
+
+1. Click Create on the top right button.
+
+1. Confirm the transaction in Kukai and come back to the app.
+
+ Perfect, the round is starting!
+
+1. Now you can run the web version on VScode, connect with alice, and play with your 2 players.
+
+ Watch the video here to see how to play a full party.
+
+ [![Shifumi](https://img.youtube.com/vi/SHg8VPmF_NY/0.jpg)](https://www.youtube.com/watch?v=SHg8VPmF_NY)
+
+1. Publish your app to the Google Play store.
+
+ To publish your app to the Android store, read the Google documentation.
+ You need a developer account: https://developer.android.com/distribute/console/
+
+ It costs 25\$ for life (for information: an Apple developer account costs 99$/ year).
+
+1. In Android studio, go to **Build > Generate Signed bundle / APK**.
+
+ ![sign.png](/img/tutorials/mobile-sign.png)
+
+ Follow the Google instructions to set your keystore and click **Next**.
+ Watch where the binary is stored and upload it to the Google Play console app.
+
+ After passing a (long) configuration of your application on Google Play Store and passing all Google validations, your app is published and everyone can download it on Earth.
+
+## Summary
+
+Having a Web3 game has many advantages like the transparency and inheritance of in-game currency. Developing the dApp is not so different from a Web2 application. Also the process of bundling to Android and iOS is similar and uses the common tools from Google and Apple.
+
+I hope you enjoyed this tutorial and don't hesitate to leave feedback to the Marigold team!
diff --git a/sidebars.js b/sidebars.js
index 571be4fb2..ce58d8b26 100644
--- a/sidebars.js
+++ b/sidebars.js
@@ -326,6 +326,20 @@ const sidebars = {
'tutorials/build-an-nft-marketplace/part-4',
],
},
+ {
+ type: 'category',
+ label: 'Create a mobile game',
+ link: {
+ type: 'doc',
+ id: 'tutorials/mobile',
+ },
+ items: [
+ 'tutorials/mobile/part-1',
+ 'tutorials/mobile/part-2',
+ 'tutorials/mobile/part-3',
+ 'tutorials/mobile/part-4',
+ ],
+ },
],
},
],
diff --git a/static/img/tutorials/mobile-alice.png b/static/img/tutorials/mobile-alice.png
new file mode 100644
index 000000000..22ccb00df
Binary files /dev/null and b/static/img/tutorials/mobile-alice.png differ
diff --git a/static/img/tutorials/mobile-build.png b/static/img/tutorials/mobile-build.png
new file mode 100644
index 000000000..b01006dac
Binary files /dev/null and b/static/img/tutorials/mobile-build.png differ
diff --git a/static/img/tutorials/mobile-home.png b/static/img/tutorials/mobile-home.png
new file mode 100644
index 000000000..8e9af3dca
Binary files /dev/null and b/static/img/tutorials/mobile-home.png differ
diff --git a/static/img/tutorials/mobile-kukai.png b/static/img/tutorials/mobile-kukai.png
new file mode 100644
index 000000000..12e6c463a
Binary files /dev/null and b/static/img/tutorials/mobile-kukai.png differ
diff --git a/static/img/tutorials/mobile-picHOME.png b/static/img/tutorials/mobile-picHOME.png
new file mode 100644
index 000000000..23b880c85
Binary files /dev/null and b/static/img/tutorials/mobile-picHOME.png differ
diff --git a/static/img/tutorials/mobile-run.png b/static/img/tutorials/mobile-run.png
new file mode 100644
index 000000000..e5bb365c6
Binary files /dev/null and b/static/img/tutorials/mobile-run.png differ
diff --git a/static/img/tutorials/mobile-sign.png b/static/img/tutorials/mobile-sign.png
new file mode 100644
index 000000000..33248de5a
Binary files /dev/null and b/static/img/tutorials/mobile-sign.png differ