Construindo um jogo usando Near, Aurora e BOS

Aurora Brasil
Aurora Platform
Published in
8 min readMay 5, 2023

Introdução

Nesta postagem do blog, exploramos a criação de um jogo simples Tic Tac Toe usando a tecnologia do ecossistema Near. Isso inclui o uso da Aurora para uma experiência de integração perfeita (transações gratuitas), Near para lógica complexa de contrato inteligente e BOS para o front-end. O resultado final é um aplicativo gratuito e totalmente descentralizado que qualquer pessoa pode pegar e jogar.

Tic Tac Toe foi escolhido como exemplo porque é fácil de entender e pequeno o suficiente para que o código seja usado em uma postagem de blog. Mas essa mesma pilha de arquitetura e tecnologia também pode ser aplicada a projetos não triviais! Por exemplo, o contrato inteligente pode estar executando um mecanismo de xadrez em vez de um mecanismo Tic Tac Toe. Ou pode não ter nada a ver com jogos, e o contrato inteligente executa um verificador de prova de conhecimento zero para algum aplicativo. As possibilidades são infinitas!

Esta postagem mostra alguns trechos de código como partes independentes; no entanto, nem todo o código é mostrado. O código completo dos contratos inteligentes usados ​​neste exemplo está disponível no GitHub. O código front-end completo está disponível no BOS.

Arquitetura

Este projeto é composto por três componentes:

  1. Um contrato inteligente sem estado escrito em Rust e implantado em Near, que recebe um estado e entrada do tabuleiro Tic Tac Toe e retorna um estado atualizado como saída.
  2. Um contrato Solidity implantado em Aurora, com o qual os usuários interagem para iniciar os jogos Tic Tac Toe e fazer seus movimentos. Este contrato usa Near para fazer um oponente de computador, e persiste os jogos dos usuários em armazenamento.
  3. Um front-end escrito em JavaScript que é alimentado por BOS. É com isso que o usuário interage diretamente e envia as transações para o contrato inteligente do Solidity em Aurora.

Todos esses componentes são executados em cima de uma plataforma blockchain; Não precisei adquirir nenhum recurso de hardware para implantar este dApp e, no entanto, qualquer pessoa pode interagir com ele.

Uma maneira de pensar nessa arquitetura é análoga a um aplicativo Web2 que usa JavaScript (JS) e WebAssembly (Wasm). O código JS lida com o estado (cookies, DOM, etc.), enquanto o Wasm lida com a computação mais pesada que seria ineficiente em JS diretamente. Em nosso caso, o código Solidity lida com o estado enquanto o código Rust em Near lida com a computação mais pesada (e, em última análise, também é executado como Wasm, tornando a analogia ainda mais forte).

Nas próximas seções, discutiremos cada um desses componentes com algum detalhe.

Contrato Near

Conforme descrito acima, o contrato Near não tem estado e lida com a lógica mais complexa de nosso aplicativo, neste caso, o player de computador Tic Tac Toe. É muito limpo e fácil escrever esse código em Rust. Temos um módulo onde são definidos alguns tipos básicos:

#[repr(i8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellState {
Empty = 0,
X = 1,
O = -1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GameState {
/// Row-major representation of the board
pub board: [CellState; BOARD_SIZE],
}

E outro módulo que usa esses tipos para analisar uma posição Tic Tac Toe e, em seguida, fazer uma boa jogada:

pub enum MoveResult {
Move { updated_state: GameState },
GameOver { winner: CellState },
}
pub fn get_move(state: GameState) -> MoveResult {
// ... elided for brevity
}
enum Evaluation {
Sums {
sums: [i8; ROW_SIZE + ROW_SIZE + 2],
total: i8,
},
GameOver {
winner: CellState,
},
}
fn evaluate_position(state: GameState) -> Evaluation {
// ... elided for brevity
}

Por fim, há um ponto de entrada do contrato escrito usando o Near SDK:

#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, Default)]
pub struct TicTacToe;
#[near_bindgen]
impl TicTacToe {
pub fn get_move(&self, state: String) -> GetMoveResponse {
let parsed_state: types::GameState = state
.parse()
.unwrap_or_else(|_| env::panic_str("Invalid state string"));
match logic::get_move(parsed_state) {
logic::MoveResult::Move { updated_state } => {
let serialized_state = updated_state.to_string();
let winner = match logic::get_move(updated_state) {
logic::MoveResult::GameOver { winner } => Some(format!("{winner:?}")),
logic::MoveResult::Move { .. } => None,
};
GetMoveResponse {
updated_state: serialized_state,
winner,
}
}
logic::MoveResult::GameOver { winner } => GetMoveResponse {
updated_state: state,
winner: Some(format!("{winner:?}")),
},
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct GetMoveResponse {
updated_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
winner: Option<String>,
}

O bom de ser um contrato sem estado é que você pode interagir com ele inteiramente usando chamadas de exibição (essencialmente usando Near como uma plataforma de computação sem servidor). Eu escrevi um front-end desenvolvido pela BOS para interagir diretamente com este contrato Near para ilustrar este ponto. Como nenhuma transação é realmente enviada para a cadeia, ela é muito mais responsiva do que o produto final que estamos desenvolvendo neste post. Mas a computação sem estado tem aplicações limitadas, portanto, confirmar transações na cadeia para acessar o estado ainda é importante em casos de uso do mundo real. Para isso, estamos fazendo uso da Aurora.

Contrato Aurora

O contrato Solidity implantado na Aurora lida com o gerenciamento de estado e é o contrato para o qual os usuários fazem transações. Este contrato usa o recurso de cross-contract calls (XCC) da Aurora para chamar o contrato Near diretamente quando ele precisa obter o próximo movimento do oponente do computador. Aqui está essencialmente a aparência do código (alguns detalhes omitidos por brevidade):

contract TicTacToe is AccessControl {
using AuroraSdk for NEAR;
using AuroraSdk for PromiseCreateArgs;
using AuroraSdk for PromiseWithCallback;
using AuroraSdk for PromiseResult;
using Codec for bytes;
    constructor(string memory _ticTacToeAccountId, IERC20 _wNEAR) {
ticTacToeAccountId = _ticTacToeAccountId;
near = AuroraSdk.initNear(_wNEAR);
wNEAR = _wNEAR;
_grantRole(OWNER_ROLE, msg.sender);
_grantRole(CALLBACK_ROLE, AuroraSdk.nearRepresentitiveImplicitAddress(address(this)));
}
// Start a new game where `player_preference = 0` means player goes second (plays O) and
// `player_preference > 0` means the plater goes first (plays X).
function newGame(uint256 player_preference) public {
address player = msg.sender;
games[player] = 0;
if (player_preference == 0) {
takeComputerTurn(player, 0);
}
}
function takePlayerTurn(uint256 move) public {
address player = msg.sender;
uint256 currentState = games[player];
require(currentState < 0x1000000000000000000, "Game Over");
require(legalMoves[move] > 0, "Invalid move");
require(move & currentState == 0, "Move at filled cell");
currentState ^= move;
games[player] = currentState;
takeComputerTurn(player, currentState);
}
function getGameState(address player) public view returns (uint256) {
return games[player];
}
// Call the tic tac toe contract on NEAR to make a move.
function takeComputerTurn(address player, uint256 initialState) private {
bytes memory data = abi.encodePacked("{\"state\":\"", encodeStateForNear(initialState), "\"}");
PromiseCreateArgs memory callGetMove = near.call(ticTacToeAccountId, "get_move", data, 0, GET_MOVE_NEAR_GAS);
PromiseCreateArgs memory callback = near.auroraCall(
address(this),
abi.encodeWithSelector(this.computerTurnCallback.selector, player),
0,
COMPUTER_TURN_CALLBACK_NEAR_GAS
);
callGetMove.then(callback).transact();
}
// Get the result of calling the NEAR contract. Update the internal state of this contract.
function computerTurnCallback(address player) public onlyRole(CALLBACK_ROLE) {
PromiseResult memory result = AuroraSdk.promiseResult(0);
if (result.status != PromiseResultStatus.Successful) {
revert("Tic tac toe Near call failed");
}
// output is of the form `{"updated_state":"<NINE_STATE_BYTES>","winner":"CellState::<X|O|Empty>"}`
// where the `winner` field is optional.
uint256 updatedState = decodeNearState(result.output);
if (result.output.length > 37) {
// Indicate the game is over by setting some higher bytes
updatedState ^= 0x1100000000000000000000;
}
games[player] = updatedState; emit Turn(player, string(result.output));
}
}

O bom de usar a Aurora para as transações on-chain é que podemos facilmente integrar os usuários com as 50 transações gratuitas que a Aurora fornece a qualquer usuário (a integração é mais simples porque eles não precisam comprar cripto para cobrir as taxas de gás; eles podem apenas comece a jogar nosso jogo imediatamente).

A peça final do quebra-cabeça é que haja um front-end com o qual o usuário interaja e faça transações para este contrato em seu nome.

BOS front-end

O Blockchain Operating System (BOS) permite a criação de front-ends descentralizados onde o código é hospedado na blockchain Near. Os gateways BOS (que qualquer um pode executar) fornecem o código aos usuários finais. Isso é conveniente para mim como desenvolvedor porque não preciso hospedar nenhum servidor para meu front-end; Eu sei que os gateways BOS cuidarão disso para mim.

Se você estiver familiarizado com o uso da estrutura React JavaScript, não terá problemas para escrever front-ends no BOS. Eu mesmo não sou muito desenvolvedor de JS, e até eu achei razoavelmente fácil usar o BOS para fazer um front-end simples (lembre-se disso quando olhar para o front-end; não sou um front-end profissional desenvolvedor). O código-fonte completo pode ser visualizado no próprio BOS, mas aqui estão alguns destaques do código:

const sender = Ethers.send("eth_requestAccounts", [])[0];
if (!sender) return <Web3Connect connectLabel="Connect with Web3" />;const contractAbi = fetch(
"https://gist.githubusercontent.com/birchmd/3db801d6115ceaaafb3d7e8fd94e0dc2/raw/5aa660a746d8f137df2c77142bfba36057dab6ef/TicTacToe.abi.json"
);
const iface = new ethers.utils.Interface(contractAbi.body);const contract = new ethers.Contract(
contract_address,
contractAbi.body,
Ethers.provider().getSigner()
);
initState({
board: {
isGameOver: false,
board: [".", ".", ".", ".", ".", ".", ".", ".", "."],
},
pendingPlayer: "X",
player: "X",
playerNumber: 1,
expectNewState: true,
firstQuery: true,
startingNewGame: false,
});
const newGame = () => {
// Don't allow sending new transactions while waiting
// for the state to update.
if (state.expectNewState) {
return;
}
let player_prefernece; if (state.pendingPlayer == "X") {
State.update({ player: "X", playerNumber: 1 });
player_prefernece = 1;
} else {
State.update({ player: "O", playerNumber: 17 });
player_prefernece = 0;
}
contract.newGame(player_prefernece).then((tx) => {
State.update({ expectNewState: true, startingNewGame: true });
tx.wait().then((rx) => {
console.log(rx);
getGameState();
});
});
};
const playerMove = (index) => {
if (
!state.expectNewState &&
!state.board.isGameOver &&
state.board.board[index] == "."
) {
const move =
"0x" +
(
new BN(state.playerNumber) * new BN(256).pow(new BN(8 - index))
).toString(16);
contract.takePlayerTurn(move).then((tx) => {
State.update({ expectNewState: true, startingNewGame: false });
tx.wait().then((rx) => {
console.log(rx);
getGameState();
});
});
}
};
const getGameState = () => {
// shot curcuit to avoid constantly hitting the RPC
if (!state.expectNewState) {
return;
}
const encodedData = iface.encodeFunctionData("getGameState", [sender]); Ethers.provider()
.call({
to: contract_address,
data: encodedData,
})
.then((boardHex) => {
const result = parseBoardHex(boardHex);
const expectNewState =
state.expectNewState &&
!state.firstQuery &&
result.isGameOver == state.board.isGameOver &&
JSON.stringify(result.board) === JSON.stringify(state.board.board);
State.update({
board: result,
player,
playerNumber,
winner,
expectNewState,
firstQuery: false,
});
});
};
return (
<>
{getGameState()}
<table>
<tr>
<TopLeftCell onClick={() => playerMove(0)}>
{state.board.board[0]}
</TopLeftCell>
<TopCenterCell onClick={() => playerMove(1)}>
{state.board.board[1]}
</TopCenterCell>
<TopRightCell onClick={() => playerMove(2)}>
{state.board.board[2]}
</TopRightCell>
</tr>
<tr>
<MiddleLeftCell onClick={() => playerMove(3)}>
{state.board.board[3]}
</MiddleLeftCell>
<MiddleCenterCell onClick={() => playerMove(4)}>
{state.board.board[4]}
</MiddleCenterCell>
<MiddleRightCell onClick={() => playerMove(5)}>
{state.board.board[5]}
</MiddleRightCell>
</tr>
<tr>
<BottomLeftCell onClick={() => playerMove(6)}>
{state.board.board[6]}
</BottomLeftCell>
<BottomCenterCell onClick={() => playerMove(7)}>
{state.board.board[7]}
</BottomCenterCell>
<BottomRightCell onClick={() => playerMove(8)}>
{state.board.board[8]}
</BottomRightCell>
</tr>
</table>
<br></br>
{state.board.isGameOver && <div>{state.winner}</div>}
{state.expectNewState ? (
<div>
<p>Waiting for new data from RPC...</p>
</div>
) : (
<div />
)}
<br></br>
<label for="selectPlayer">Play as:</label>
<select
id="selectPlayer"
onChange={(e) => State.update({ pendingPlayer: e.target.value })}
>
<option value="X">X</option>
<option value="O">O</option>
</select>
<div class="mb-3">
<button onClick={newGame}>New Game</button>
</div>
</>
);

Demonstração e Conclusão

Este aplicativo está ao vivo no BOS agora! Você pode brincar com ele aqui ou ver uma demo pré-gravada aqui. Para usar o aplicativo de demonstração, certifique-se de que seu MetaMask esteja conectado a Aurora Testnet (a interface BOS pode dizer que a rede não é reconhecida, mas ainda deve funcionar para enviar as transações).

Este post explorou a pilha de tecnologia Near para criar aplicativos totalmente descentralizados. Todo esse aplicativo é hospedado na cadeia, do front ao back-end. A blockchain Near fornece a camada de computação básica com seu tempo de execução baseado em WebAssembly, Aurora fornece a camada de persistência, mantendo fácil integração em transações gratuitas, e o BOS fornece um front-end sem servidor construído no blockchain Near.

Espero que você tenha gostado desta postagem no blog e esteja se sentindo inspirado a criar alguns usando Aurora, Near e BOS!

--

--